From 0cd2095af326a480d5d9dacfb409e7df3d8b412a Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 17:08:30 +0200 Subject: [PATCH 001/327] Added MapPath and ReverseMapPath to all Fusion paths --- .../hosts/fusion/plugins/create/create_saver.py | 3 ++- openpype/hosts/fusion/plugins/load/load_sequence.py | 5 +++-- .../hosts/fusion/plugins/publish/collect_render.py | 13 +++++++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..9a3640b176 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -166,7 +166,8 @@ class CreateSaver(NewCreator): filepath = self.temp_rendering_path_template.format( **formatting_data) - tool["Clip"] = os.path.normpath(filepath) + comp = get_current_comp() + tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) # Rename tool if tool.Name != subset: diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index 20be5faaba..fde5b27e70 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -161,7 +161,8 @@ class FusionLoadSequence(load.LoaderPlugin): with comp_lock_and_undo_chunk(comp, "Create Loader"): args = (-32768, -32768) tool = comp.AddTool("Loader", *args) - tool["Clip"] = path + comp = get_current_comp() + tool["Clip"] = comp.ReverseMapPath(path) # Set global in point to start frame (if in version.data) start = self._get_start(context["version"], tool) @@ -244,7 +245,7 @@ class FusionLoadSequence(load.LoaderPlugin): "TimeCodeOffset", ), ): - tool["Clip"] = path + tool["Clip"] = comp.ReverseMapPath(path) # Set the global in to the start frame of the sequence global_in_changed = loader_shift(tool, start, relative=False) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index a20a142701..62dd295e59 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -4,7 +4,10 @@ import pyblish.api from openpype.pipeline import publish from openpype.pipeline.publish import RenderInstance -from openpype.hosts.fusion.api.lib import get_frame_path +from openpype.hosts.fusion.api.lib import ( + get_frame_path, + get_current_comp, +) @attr.s @@ -146,9 +149,11 @@ class CollectFusionRender( start = render_instance.frameStart - render_instance.handleStart end = render_instance.frameEnd + render_instance.handleEnd - path = ( - render_instance.tool["Clip"] - [render_instance.workfileComp.TIME_UNDEFINED] + comp = get_current_comp() + path = comp.MapPath( + render_instance.tool["Clip"][ + render_instance.workfileComp.TIME_UNDEFINED + ] ) output_dir = os.path.dirname(path) render_instance.outputDir = output_dir From 811f54763e9d3b5642bc3cc9c66b4b322c1913bf Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 17:08:42 +0200 Subject: [PATCH 002/327] Black formatting --- .../fusion/plugins/create/create_saver.py | 72 ++++++++----------- .../fusion/plugins/publish/collect_render.py | 12 ++-- 2 files changed, 32 insertions(+), 52 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 9a3640b176..42d96ab82f 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -14,7 +14,7 @@ from openpype.pipeline import ( legacy_io, Creator as NewCreator, CreatedInstance, - Anatomy + Anatomy, ) @@ -27,28 +27,20 @@ class CreateSaver(NewCreator): description = "Fusion Saver to generate image sequence" icon = "fa5.eye" - instance_attributes = [ - "reviewable" - ] - default_variants = [ - "Main", - "Mask" - ] + instance_attributes = ["reviewable"] + default_variants = ["Main", "Mask"] # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( - "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}") + "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}" + ) def create(self, subset_name, instance_data, pre_create_data): - self.pass_pre_attributes_to_instance( - instance_data, - pre_create_data - ) + self.pass_pre_attributes_to_instance(instance_data, pre_create_data) - instance_data.update({ - "id": "pyblish.avalon.instance", - "subset": subset_name - }) + instance_data.update( + {"id": "pyblish.avalon.instance", "subset": subset_name} + ) # TODO: Add pre_create attributes to choose file format? file_format = "OpenEXRFormat" @@ -156,15 +148,12 @@ class CreateSaver(NewCreator): # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) - formatting_data.update({ - "workdir": workdir, - "frame": "0" * frame_padding, - "ext": "exr" - }) + formatting_data.update( + {"workdir": workdir, "frame": "0" * frame_padding, "ext": "exr"} + ) # build file path to render - filepath = self.temp_rendering_path_template.format( - **formatting_data) + filepath = self.temp_rendering_path_template.format(**formatting_data) comp = get_current_comp() tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) @@ -200,7 +189,7 @@ class CreateSaver(NewCreator): attr_defs = [ self._get_render_target_enum(), self._get_reviewable_bool(), - self._get_frame_range_enum() + self._get_frame_range_enum(), ] return attr_defs @@ -208,11 +197,7 @@ class CreateSaver(NewCreator): """Settings for publish page""" return self.get_pre_create_attr_defs() - def pass_pre_attributes_to_instance( - self, - instance_data, - pre_create_data - ): + def pass_pre_attributes_to_instance(self, instance_data, pre_create_data): creator_attrs = instance_data["creator_attributes"] = {} for pass_key in pre_create_data.keys(): creator_attrs[pass_key] = pre_create_data[pass_key] @@ -235,13 +220,13 @@ class CreateSaver(NewCreator): frame_range_options = { "asset_db": "Current asset context", "render_range": "From render in/out", - "comp_range": "From composition timeline" + "comp_range": "From composition timeline", } return EnumDef( "frame_range_source", items=frame_range_options, - label="Frame range source" + label="Frame range source", ) def _get_reviewable_bool(self): @@ -251,23 +236,22 @@ class CreateSaver(NewCreator): label="Review", ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings, system_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings - plugin_settings = ( - project_settings["fusion"]["create"][self.__class__.__name__] - ) + plugin_settings = project_settings["fusion"]["create"][ + self.__class__.__name__ + ] # individual attributes - self.instance_attributes = plugin_settings.get( - "instance_attributes") or self.instance_attributes - self.default_variants = plugin_settings.get( - "default_variants") or self.default_variants + self.instance_attributes = ( + plugin_settings.get("instance_attributes") + or self.instance_attributes + ) + self.default_variants = ( + plugin_settings.get("default_variants") or self.default_variants + ) self.temp_rendering_path_template = ( plugin_settings.get("temp_rendering_path_template") or self.temp_rendering_path_template diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index 62dd295e59..9e48cc000e 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -25,16 +25,13 @@ class FusionRenderInstance(RenderInstance): class CollectFusionRender( - publish.AbstractCollectRender, - publish.ColormanagedPyblishPluginMixin + publish.AbstractCollectRender, publish.ColormanagedPyblishPluginMixin ): - order = pyblish.api.CollectorOrder + 0.09 label = "Collect Fusion Render" hosts = ["fusion"] def get_instances(self, context): - comp = context.data.get("currentComp") comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat") aspect_x = comp_frame_format_prefs["AspectX"] @@ -74,7 +71,7 @@ class CollectFusionRender( asset=inst.data["asset"], task=task_name, attachTo=False, - setMembers='', + setMembers="", publish=True, name=subset_name, resolutionWidth=comp_frame_format_prefs.get("Width"), @@ -93,7 +90,7 @@ class CollectFusionRender( frameStep=1, fps=comp_frame_format_prefs.get("Rate"), app_version=comp.GetApp().Version, - publish_attributes=inst.data.get("publish_attributes", {}) + publish_attributes=inst.data.get("publish_attributes", {}), ) render_target = inst.data["creator_attributes"]["render_target"] @@ -166,8 +163,7 @@ class CollectFusionRender( for frame in range(start, end + 1): expected_files.append( os.path.join( - output_dir, - f"{head}{str(frame).zfill(padding)}{ext}" + output_dir, f"{head}{str(frame).zfill(padding)}{ext}" ) ) From c5b05a95c6799284bf688ef8a0b45e67841dc6b4 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 10 Aug 2023 11:42:08 +0100 Subject: [PATCH 003/327] Basic implementation --- .../blender/plugins/create/create_render.py | 49 +++++ .../blender/plugins/publish/collect_render.py | 81 ++++++++ .../publish/submit_blender_deadline.py | 175 ++++++++++++++++++ .../plugins/publish/submit_publish_job.py | 2 +- 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/blender/plugins/create/create_render.py create mode 100644 openpype/hosts/blender/plugins/publish/collect_render.py create mode 100644 openpype/modules/deadline/plugins/publish/submit_blender_deadline.py diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py new file mode 100644 index 0000000000..8323b88cfe --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -0,0 +1,49 @@ +"""Create render.""" + +import bpy + +from openpype.pipeline import get_current_task_name +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES + + +class CreateRenderlayer(plugin.Creator): + """Single baked camera""" + + name = "renderingMain" + label = "Render" + family = "renderlayer" + icon = "eye" + + render_settings = {} + + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + + def _process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) + self.data['task'] = get_current_task_name() + lib.imprint(asset_group, self.data) + + if (self.options or {}).get("useSelection"): + selected = lib.get_selection() + for obj in selected: + asset_group.objects.link(obj) + elif (self.options or {}).get("asset_group"): + obj = (self.options or {}).get("asset_group") + asset_group.objects.link(obj) + + return asset_group diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py new file mode 100644 index 0000000000..c0d314e466 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""Collect render data.""" + +import os +import re + +import bpy + +import pyblish.api + + +class CollectBlenderRender(pyblish.api.InstancePlugin): + """Gather all publishable render layers from renderSetup.""" + + order = pyblish.api.CollectorOrder + 0.01 + hosts = ["blender"] + families = ["renderlayer"] + label = "Collect Render Layers" + sync_workfile_version = False + + def process(self, instance): + context = instance.context + + filepath = context.data["currentFile"].replace("\\", "/") + + frame_start = context.data["frameStart"] + frame_end = context.data["frameEnd"] + frame_handle_start = context.data["frameStartHandle"] + frame_handle_end = context.data["frameEndHandle"] + + instance.data.update({ + "frameStart": frame_start, + "frameEnd": frame_end, + "frameStartHandle": frame_handle_start, + "frameEndHandle": frame_handle_end, + "fps": context.data["fps"], + "byFrameStep": bpy.context.scene.frame_step, + "farm": True, + "toBeRenderedOn": "deadline", + }) + + # instance.data["expectedFiles"] = self.generate_expected_files( + # instance, filepath) + + expected_files = [] + + for frame in range( + int(frame_start), + int(frame_end) + 1, + int(bpy.context.scene.frame_step), + ): + frame_str = str(frame).rjust(4, "0") + expected_files.append(f"C:/tmp/{frame_str}.png") + + instance.data["expectedFiles"] = expected_files + + self.log.debug(instance.data["expectedFiles"]) + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py new file mode 100644 index 0000000000..66be306a52 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to Deadline.""" + +import os +import getpass +import attr +from datetime import datetime + +import bpy + +from openpype.lib import is_running_from_build +from openpype.pipeline import legacy_io +from openpype.pipeline.farm.tools import iter_expected_files +from openpype.tests.lib import is_in_tests + +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +def _validate_deadline_bool_value(instance, attribute, value): + if not isinstance(value, (str, bool)): + raise TypeError(f"Attribute {attribute} must be str or bool.") + if value not in {"1", "0", True, False}: + raise ValueError( + f"Value of {attribute} must be one of '0', '1', True, False") + + +@attr.s +class BlenderPluginInfo(): + SceneFile = attr.ib(default=None) # Input + Version = attr.ib(default=None) # Mandatory for Deadline + + +class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): + label = "Submit Render to Deadline" + hosts = ["blender"] + families = ["renderlayer"] + + priority = 50 + + jobInfo = {} + pluginInfo = {} + group = None + + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="Blender") + + job_info.update(self.jobInfo) + + instance = self._instance + context = instance.context + + # Always use the original work file name for the Job name even when + # rendering is done from the published Work File. The original work + # file name is clearer because it can also have subversion strings, + # etc. which are stripped for the published file. + src_filepath = context.data["currentFile"] + src_filename = os.path.basename(src_filepath) + + if is_in_tests(): + src_filename += datetime.now().strftime("%d%m%Y%H%M%S") + + job_info.Name = f"{src_filename} - {instance.name}" + job_info.BatchName = src_filename + instance.data.get("blenderRenderPlugin", "Blender") + job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) + + # Deadline requires integers in frame range + frames = "{start}-{end}x{step}".format( + start=int(instance.data["frameStartHandle"]), + end=int(instance.data["frameEndHandle"]), + step=int(instance.data["byFrameStep"]), + ) + job_info.Frames = frames + + job_info.Pool = instance.data.get("primaryPool") + job_info.SecondaryPool = instance.data.get("secondaryPool") + job_info.Comment = context.data.get("comment") + job_info.Priority = instance.data.get("priority", self.priority) + + if self.group != "none" and self.group: + job_info.Group = self.group + + attr_values = self.get_attr_values_from_data(instance.data) + render_globals = instance.data.setdefault("renderGlobals", {}) + machine_list = attr_values.get("machineList", "") + if machine_list: + if attr_values.get("whitelist", True): + machine_list_key = "Whitelist" + else: + machine_list_key = "Blacklist" + render_globals[machine_list_key] = machine_list + + job_info.Priority = attr_values.get("priority") + job_info.ChunkSize = attr_values.get("chunkSize") + + # Add options from RenderGlobals + render_globals = instance.data.get("renderGlobals", {}) + job_info.update(render_globals) + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV" + "IS_TEST" + ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + for key in keys: + value = environment.get(key) + if not value: + continue + job_info.EnvironmentKeyValue[key] = value + + # to recognize job from PYPE for turning Event On/Off + job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" + + # Adding file dependencies. + if self.asset_dependencies: + dependencies = instance.context.data["fileDependencies"] + for dependency in dependencies: + job_info.AssetDependency += dependency + + # Add list of expected files to job + # --------------------------------- + exp = instance.data.get("expectedFiles") + for filepath in iter_expected_files(exp): + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) + + return job_info + + def get_plugin_info(self): + instance = self._instance + context = instance.context + + plugin_info = BlenderPluginInfo( + SceneFile=self.scene_path, + Version=bpy.app.version_string, + ) + + plugin_payload = attr.asdict(plugin_info) + + # Patching with pluginInfo from settings + for key, value in self.pluginInfo.items(): + plugin_payload[key] = value + + return plugin_payload + + def process(self, instance): + output_dir = "C:/tmp" + instance.data["outputDir"] = output_dir + + super(BlenderSubmitDeadline, self).process(instance) + + # TODO: Avoid the need for this logic here, needed for submit publish + # Store output dir for unified publisher (filesequence) + # output_dir = os.path.dirname(instance.data["expectedFiles"][0]) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index ec182fcd66..47cb441143 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -95,7 +95,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", - "celaction", "aftereffects", "harmony"] + "celaction", "aftereffects", "harmony", "blender"] families = ["render.farm", "prerender.farm", "renderlayer", "imagesequence", From fb76d6348155b6f0db6dee923d74a653921d3707 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 10 Aug 2023 17:41:02 +0100 Subject: [PATCH 004/327] Implemented settings and removed hardcoded paths --- .../blender/plugins/publish/collect_render.py | 150 +++++++++++++----- .../publish/submit_blender_deadline.py | 41 +++-- .../defaults/project_settings/blender.json | 4 + .../defaults/project_settings/deadline.json | 9 ++ .../schema_project_blender.json | 31 ++++ .../schema_project_deadline.json | 44 +++++ 6 files changed, 228 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index c0d314e466..eb37d5b946 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -6,6 +6,12 @@ import re import bpy +from openpype.pipeline import ( + get_current_project_name, +) +from openpype.settings import ( + get_project_settings, +) import pyblish.api @@ -18,16 +24,117 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): label = "Collect Render Layers" sync_workfile_version = False + @staticmethod + def get_default_render_folder(settings): + """Get default render folder from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["default_render_image_folder"]) + + @staticmethod + def get_image_format(settings): + """Get image format from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["image_format"]) + + @staticmethod + def get_render_product(file_path, render_folder, file_name, instance, ext): + output_file = os.path.join( + file_path, render_folder, file_name, instance.name) + + render_product = f"{output_file}.####.{ext}" + render_product = render_product.replace("\\", "/") + + return render_product + + @staticmethod + def generate_expected_files( + render_product, frame_start, frame_end, frame_step + ): + path = os.path.dirname(render_product) + file = os.path.basename(render_product) + + expected_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + expected_file = os.path.join(path, re.sub("#+", frame_str, file)) + expected_files.append(expected_file.replace("\\", "/")) + + return expected_files + + @staticmethod + def set_render_format(ext): + image_settings = bpy.context.scene.render.image_settings + + if ext == "exr": + image_settings.file_format = "OPEN_EXR" + elif ext == "bmp": + image_settings.file_format = "BMP" + elif ext == "iris": + image_settings.file_format = "IRIS" + elif ext == "png": + image_settings.file_format = "PNG" + elif ext == "jpeg": + image_settings.file_format = "JPEG" + elif ext == "jpeg2000": + image_settings.file_format = "JPEG2000" + elif ext == "tga": + image_settings.file_format = "TARGA" + elif ext == "tga_raw": + image_settings.file_format = "TARGA_RAW" + elif ext == "tiff": + image_settings.file_format = "TIFF" + + @staticmethod + def set_render_camera(instance): + # There should be only one camera in the instance + found = False + for obj in instance: + if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA": + bpy.context.scene.camera = obj + found = True + break + + assert found, "No camera found in the render instance" + def process(self, instance): context = instance.context filepath = context.data["currentFile"].replace("\\", "/") + file_path = os.path.dirname(filepath) + file_name = os.path.basename(filepath) + file_name, _ = os.path.splitext(file_name) + + project = get_current_project_name() + settings = get_project_settings(project) + + render_folder = self.get_default_render_folder(settings) + ext = self.get_image_format(settings) + + render_product = self.get_render_product( + file_path, render_folder, file_name, instance, ext) + + # We set the render path, the format and the camera + bpy.context.scene.render.filepath = render_product + self.set_render_format(ext) + self.set_render_camera(instance) + + # We save the file to save the render settings + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] frame_handle_start = context.data["frameStartHandle"] frame_handle_end = context.data["frameEndHandle"] + expected_files = self.generate_expected_files( + render_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step)) + instance.data.update({ "frameStart": frame_start, "frameEnd": frame_end, @@ -36,46 +143,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): "fps": context.data["fps"], "byFrameStep": bpy.context.scene.frame_step, "farm": True, - "toBeRenderedOn": "deadline", + "expectedFiles": expected_files, }) - # instance.data["expectedFiles"] = self.generate_expected_files( - # instance, filepath) - - expected_files = [] - - for frame in range( - int(frame_start), - int(frame_end) + 1, - int(bpy.context.scene.frame_step), - ): - frame_str = str(frame).rjust(4, "0") - expected_files.append(f"C:/tmp/{frame_str}.png") - - instance.data["expectedFiles"] = expected_files - - self.log.debug(instance.data["expectedFiles"]) - - def generate_expected_files(self, instance, path): - """Create expected files in instance data""" - - dir = os.path.dirname(path) - file = os.path.basename(path) - - if "#" in file: - def replace(match): - return "%0{}d".format(len(match.group())) - - file = re.sub("#+", replace, file) - - if "%" not in file: - return path - - expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - for i in range(int(start), (int(end) + 1)): - expected_files.append( - os.path.join(dir, (file % i)).replace("\\", "/")) - - return expected_files + self.log.info(f"data: {instance.data}") diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 66be306a52..761c5b7b06 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -29,6 +29,7 @@ def _validate_deadline_bool_value(instance, attribute, value): class BlenderPluginInfo(): SceneFile = attr.ib(default=None) # Input Version = attr.ib(default=None) # Mandatory for Deadline + SaveFile = attr.ib(default=True) class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): @@ -36,8 +37,9 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): hosts = ["blender"] families = ["renderlayer"] + use_published = True priority = 50 - + chunk_size = 1 jobInfo = {} pluginInfo = {} group = None @@ -148,12 +150,10 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): - instance = self._instance - context = instance.context - plugin_info = BlenderPluginInfo( SceneFile=self.scene_path, Version=bpy.app.version_string, + SaveFile=True, ) plugin_payload = attr.asdict(plugin_info) @@ -164,12 +164,33 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return plugin_payload - def process(self, instance): - output_dir = "C:/tmp" + def process_submission(self): + instance = self._instance + + expected_files = instance.data["expectedFiles"] + if not expected_files: + raise RuntimeError("No Render Elements found!") + + output_dir = os.path.dirname(expected_files[0]) instance.data["outputDir"] = output_dir + instance.data["toBeRenderedOn"] = "deadline" - super(BlenderSubmitDeadline, self).process(instance) + file = os.path.basename(bpy.context.scene.render.filepath) + bpy.context.scene.render.filepath = os.path.join(output_dir, file) + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) - # TODO: Avoid the need for this logic here, needed for submit publish - # Store output dir for unified publisher (filesequence) - # output_dir = os.path.dirname(instance.data["expectedFiles"][0]) + self.log.debug(f"expected_files[0]: {expected_files[0]}") + self.log.debug(f"Output dir: {output_dir}") + + payload = self.assemble_payload() + return self.submit(payload) + + def from_published_scene(self): + """ Do not overwrite expected files. + + Use published is set to True, so rendering will be triggered + from published scene (in 'publish' folder). Default implementation + of abstract class renames expected (eg. rendered) files accordingly + which is not needed here. + """ + return super().from_published_scene(False) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index df865adeba..333a1fed56 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -17,6 +17,10 @@ "rules": {} } }, + "RenderSettings": { + "default_render_image_folder": "renders/blender", + "image_format": "exr" + }, "workfile_builder": { "create_first_version": false, "custom_templates": [] diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 1b8c8397d7..33ea533863 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -99,6 +99,15 @@ "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": true, + "optional": false, + "active": true, + "use_published": true, + "priority": 50, + "chunk_size": 10, + "group": "none" + }, "ProcessSubmittedJobOnFarm": { "enabled": true, "deadline_department": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index aeb70dfd8c..787e190de5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -54,6 +54,37 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "RenderSettings", + "label": "Render Settings", + "children": [ + { + "type": "text", + "key": "default_render_image_folder", + "label": "Default render image folder" + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "exr"}, + {"bmp": "bmp"}, + {"iris": "iris"}, + {"png": "png"}, + {"jpeg": "jpeg"}, + {"jpeg2000": "jpeg2000"}, + {"tga": "tga"}, + {"tga_raw": "tga_raw"}, + {"tiff": "tiff"} + ] + } + ] + }, { "type": "schema_template", "name": "template_workfile_options", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 6d59b5a92b..596bc30f91 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -531,6 +531,50 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "BlenderSubmitDeadline", + "label": "Blender Submit to Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frame per Task" + }, + { + "type": "text", + "key": "group", + "label": "Group Name" + } + ] + }, { "type": "dict", "collapsible": true, From a1e8a5eb4c49893291403c4283a60b8a7fad27cf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 10 Aug 2023 17:54:45 +0100 Subject: [PATCH 005/327] Removed some missed leftover code --- .../deadline/plugins/publish/submit_blender_deadline.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 761c5b7b06..b3deb39399 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -175,13 +175,6 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): instance.data["outputDir"] = output_dir instance.data["toBeRenderedOn"] = "deadline" - file = os.path.basename(bpy.context.scene.render.filepath) - bpy.context.scene.render.filepath = os.path.join(output_dir, file) - bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) - - self.log.debug(f"expected_files[0]: {expected_files[0]}") - self.log.debug(f"Output dir: {output_dir}") - payload = self.assemble_payload() return self.submit(payload) From 17b5a86c51c3d1ca3f1a16f8cb249f8c3e8d5ca4 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 11 Aug 2023 11:24:09 +0100 Subject: [PATCH 006/327] Fixed problem with image format --- .../blender/plugins/publish/collect_render.py | 8 +++----- .../projects_schema/schema_project_blender.json | 17 ++++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index eb37d5b946..11b98c76e6 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -74,19 +74,17 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): image_settings.file_format = "OPEN_EXR" elif ext == "bmp": image_settings.file_format = "BMP" - elif ext == "iris": + elif ext == "rgb": image_settings.file_format = "IRIS" elif ext == "png": image_settings.file_format = "PNG" elif ext == "jpeg": image_settings.file_format = "JPEG" - elif ext == "jpeg2000": + elif ext == "jp2": image_settings.file_format = "JPEG2000" elif ext == "tga": image_settings.file_format = "TARGA" - elif ext == "tga_raw": - image_settings.file_format = "TARGA_RAW" - elif ext == "tiff": + elif ext == "tif": image_settings.file_format = "TIFF" @staticmethod diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index 787e190de5..84efec5c0a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -72,15 +72,14 @@ "multiselection": false, "defaults": "exr", "enum_items": [ - {"exr": "exr"}, - {"bmp": "bmp"}, - {"iris": "iris"}, - {"png": "png"}, - {"jpeg": "jpeg"}, - {"jpeg2000": "jpeg2000"}, - {"tga": "tga"}, - {"tga_raw": "tga_raw"}, - {"tiff": "tiff"} + {"exr": "OpenEXR"}, + {"bmp": "BMP"}, + {"rgb": "Iris"}, + {"png": "PNG"}, + {"jpg": "JPEG"}, + {"jp2": "JPEG 2000"}, + {"tga": "Targa"}, + {"tif": "TIFF"} ] } ] From 7d7a41792e96ab3eb93a48e8c641f8bba3ec0f58 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 11 Aug 2023 14:52:15 +0100 Subject: [PATCH 007/327] Added more comments --- .../blender/plugins/publish/collect_render.py | 15 +++++++++++++++ .../plugins/publish/submit_blender_deadline.py | 11 +++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 11b98c76e6..7f060c3b7c 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -42,6 +42,17 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): @staticmethod def get_render_product(file_path, render_folder, file_name, instance, ext): + """ + Generate the path to the render product. Blender interprets the `#` + as the frame number, when it renders. + + Args: + file_path (str): The path to the blender scene. + render_folder (str): The render folder set in settings. + file_name (str): The name of the blender scene. + instance (pyblish.api.Instance): The instance to publish. + ext (str): The image format to render. + """ output_file = os.path.join( file_path, render_folder, file_name, instance.name) @@ -54,6 +65,10 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): def generate_expected_files( render_product, frame_start, frame_end, frame_step ): + """Generate the expected files for the render product. + This returns a list of files that should be rendered. It replaces + the sequence of `#` with the frame number. + """ path = os.path.dirname(render_product) file = os.path.basename(render_product) diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index b3deb39399..7aee087ddc 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -179,11 +179,10 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return self.submit(payload) def from_published_scene(self): - """ Do not overwrite expected files. - - Use published is set to True, so rendering will be triggered - from published scene (in 'publish' folder). Default implementation - of abstract class renames expected (eg. rendered) files accordingly - which is not needed here. + """ + This is needed to set the correct path for the json metadata. Because + the rendering path is set in the blend file during the collection, + and the path is adjusted to use the published scene, this ensures that + the metadata and the rendered files are in the same location. """ return super().from_published_scene(False) From 94c801ce84fb1bd99ffe57fee9bb0ea127dc03c9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 11 Aug 2023 14:52:28 +0100 Subject: [PATCH 008/327] Removed redundant code --- .../deadline/plugins/publish/submit_blender_deadline.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 7aee087ddc..9bfc4fbb07 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -17,14 +17,6 @@ from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo -def _validate_deadline_bool_value(instance, attribute, value): - if not isinstance(value, (str, bool)): - raise TypeError(f"Attribute {attribute} must be str or bool.") - if value not in {"1", "0", True, False}: - raise ValueError( - f"Value of {attribute} must be one of '0', '1', True, False") - - @attr.s class BlenderPluginInfo(): SceneFile = attr.ib(default=None) # Input From 85b49ec761deb3187ded2ee4262f23efa227c40f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 22 Aug 2023 10:52:02 +0100 Subject: [PATCH 009/327] Basic implementation for AOVs rendering --- .../blender/plugins/publish/collect_render.py | 101 ++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 7f060c3b7c..fafdd9cc2d 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -12,6 +12,10 @@ from openpype.pipeline import ( from openpype.settings import ( get_project_settings, ) +from openpype.hosts.blender.api.ops import ( + MainThreadItem, + execute_in_main_thread +) import pyblish.api @@ -41,7 +45,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): ["image_format"]) @staticmethod - def get_render_product(file_path, render_folder, file_name, instance, ext): + def get_render_product(output_path, instance): """ Generate the path to the render product. Blender interprets the `#` as the frame number, when it renders. @@ -53,10 +57,9 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): instance (pyblish.api.Instance): The instance to publish. ext (str): The image format to render. """ - output_file = os.path.join( - file_path, render_folder, file_name, instance.name) + output_file = os.path.join(output_path, instance.name) - render_product = f"{output_file}.####.{ext}" + render_product = f"{output_file}.####" render_product = render_product.replace("\\", "/") return render_product @@ -83,9 +86,13 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): @staticmethod def set_render_format(ext): + # Set Blender to save the file with the right extension + bpy.context.scene.render.use_file_extension = True + image_settings = bpy.context.scene.render.image_settings if ext == "exr": + # TODO: Check if multilayer option is selected image_settings.file_format = "OPEN_EXR" elif ext == "bmp": image_settings.file_format = "BMP" @@ -102,6 +109,86 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): elif ext == "tif": image_settings.file_format = "TIFF" + def _set_node_tree(self, output_path, instance): + # Set the scene to use the compositor node tree to render + bpy.context.scene.use_nodes = True + + tree = bpy.context.scene.node_tree + + # Get the Render Layers node + rl_node = None + for node in tree.nodes: + if node.bl_idname == "CompositorNodeRLayers": + rl_node = node + break + + # If there's not a Render Layers node, we create it + if not rl_node: + rl_node = tree.nodes.new("CompositorNodeRLayers") + + # Get the enabled output sockets, that are the active passes for the + # render. + # We also exclude some layers. + exclude_sockets = ["Image", "Alpha"] + passes = [ + socket for socket in rl_node.outputs + if socket.enabled and socket.name not in exclude_sockets + ] + + # Remove all output nodes + for node in tree.nodes: + if node.bl_idname == "CompositorNodeOutputFile": + tree.nodes.remove(node) + + # Create a new output node + output = tree.nodes.new("CompositorNodeOutputFile") + + + context = bpy.context.copy() + # context = create_blender_context() + context["node"] = output + + win = bpy.context.window_manager.windows[0] + screen = win.screen + area = screen.areas[0] + region = area.regions[0] + + context["window"] = win + context['screen'] = screen + context['area'] = area + context['region'] = region + + self.log.debug(f"context: {context}") + + # Change area type to node editor, to execute node operators + old_area_type = area.ui_type + area.ui_type = "CompositorNodeTree" + + # Remove the default input socket from the output node + bpy.ops.node.output_file_remove_active_socket(context) + + output.base_path = output_path + image_settings = bpy.context.scene.render.image_settings + output.format.file_format = image_settings.file_format + + # For each active render pass, we add a new socket to the output node + # and link it + for render_pass in passes: + bpy.ops.node.output_file_add_socket( + context, file_path=f"{instance.name}_{render_pass.name}.####") + + node_input = output.inputs[-1] + + tree.links.new(render_pass, node_input) + + # Restore the area type + area.ui_type = old_area_type + + def set_node_tree(self, output_path, instance): + """ Run the creator on Blender main thread""" + mti = MainThreadItem(self._set_node_tree, output_path, instance) + execute_in_main_thread(mti) + @staticmethod def set_render_camera(instance): # There should be only one camera in the instance @@ -128,8 +215,10 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): render_folder = self.get_default_render_folder(settings) ext = self.get_image_format(settings) - render_product = self.get_render_product( - file_path, render_folder, file_name, instance, ext) + output_path = os.path.join(file_path, render_folder, file_name) + + render_product = self.get_render_product(output_path, instance) + self.set_node_tree(output_path, instance) # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product From eb1b5425de4aa546eaaae682f2acf9209dadf359 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 22 Aug 2023 11:54:49 +0100 Subject: [PATCH 010/327] Added support for multilayer EXR --- .../blender/plugins/publish/collect_render.py | 48 ++++++++++++------- .../defaults/project_settings/blender.json | 3 +- .../schema_project_blender.json | 35 ++++++++------ 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index fafdd9cc2d..cd3b922697 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -44,6 +44,14 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): ["RenderSettings"] ["image_format"]) + @staticmethod + def get_multilayer(settings): + """Get multilayer from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["multilayer_exr"]) + @staticmethod def get_render_product(output_path, instance): """ @@ -85,15 +93,15 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): return expected_files @staticmethod - def set_render_format(ext): + def set_render_format(ext, multilayer): # Set Blender to save the file with the right extension bpy.context.scene.render.use_file_extension = True image_settings = bpy.context.scene.render.image_settings if ext == "exr": - # TODO: Check if multilayer option is selected - image_settings.file_format = "OPEN_EXR" + image_settings.file_format = ( + "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") elif ext == "bmp": image_settings.file_format = "BMP" elif ext == "rgb": @@ -109,6 +117,21 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): elif ext == "tif": image_settings.file_format = "TIFF" + def _create_context(): + context = bpy.context.copy() + + win = bpy.context.window_manager.windows[0] + screen = win.screen + area = screen.areas[0] + region = area.regions[0] + + context["window"] = win + context['screen'] = screen + context['area'] = area + context['region'] = region + + return context + def _set_node_tree(self, output_path, instance): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True @@ -143,22 +166,10 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # Create a new output node output = tree.nodes.new("CompositorNodeOutputFile") - - context = bpy.context.copy() - # context = create_blender_context() + context = self._create_context() context["node"] = output - win = bpy.context.window_manager.windows[0] - screen = win.screen - area = screen.areas[0] - region = area.regions[0] - - context["window"] = win - context['screen'] = screen - context['area'] = area - context['region'] = region - - self.log.debug(f"context: {context}") + area = context["area"] # Change area type to node editor, to execute node operators old_area_type = area.ui_type @@ -214,6 +225,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): render_folder = self.get_default_render_folder(settings) ext = self.get_image_format(settings) + multilayer = self.get_multilayer(settings) output_path = os.path.join(file_path, render_folder, file_name) @@ -222,7 +234,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product - self.set_render_format(ext) + self.set_render_format(ext, multilayer) self.set_render_camera(instance) # We save the file to save the render settings diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 333a1fed56..c375e550c2 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -19,7 +19,8 @@ }, "RenderSettings": { "default_render_image_folder": "renders/blender", - "image_format": "exr" + "image_format": "exr", + "multilayer_exr": true }, "workfile_builder": { "create_first_version": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index 84efec5c0a..d8ef1eee3e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -66,21 +66,26 @@ "label": "Default render image folder" }, { - "key": "image_format", - "label": "Output Image Format", - "type": "enum", - "multiselection": false, - "defaults": "exr", - "enum_items": [ - {"exr": "OpenEXR"}, - {"bmp": "BMP"}, - {"rgb": "Iris"}, - {"png": "PNG"}, - {"jpg": "JPEG"}, - {"jp2": "JPEG 2000"}, - {"tga": "Targa"}, - {"tif": "TIFF"} - ] + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "OpenEXR"}, + {"bmp": "BMP"}, + {"rgb": "Iris"}, + {"png": "PNG"}, + {"jpg": "JPEG"}, + {"jp2": "JPEG 2000"}, + {"tga": "Targa"}, + {"tif": "TIFF"} + ] + }, + { + "key": "multilayer_exr", + "type": "boolean", + "label": "Multilayer (EXR)" } ] }, From 9f56721334c8a7d42aa88c34ee4ef01befeb153d Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 22 Aug 2023 15:56:23 +0100 Subject: [PATCH 011/327] Implemented AOVs rendering in deadline and publishing --- openpype/hosts/blender/api/colorspace.py | 52 +++++++++++ .../blender/plugins/publish/collect_render.py | 92 ++++++++++++++----- .../publish/submit_blender_deadline.py | 3 +- 3 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 openpype/hosts/blender/api/colorspace.py diff --git a/openpype/hosts/blender/api/colorspace.py b/openpype/hosts/blender/api/colorspace.py new file mode 100644 index 0000000000..59deb514f8 --- /dev/null +++ b/openpype/hosts/blender/api/colorspace.py @@ -0,0 +1,52 @@ +import attr + +import bpy + + +@attr.s +class LayerMetadata(object): + """Data class for Render Layer metadata.""" + frameStart = attr.ib() + frameEnd = attr.ib() + + +@attr.s +class RenderProduct(object): + """Getting Colorspace as + Specific Render Product Parameter for submitting + publish job. + """ + colorspace = attr.ib() # colorspace + view = attr.ib() + productName = attr.ib(default=None) + + +class ARenderProduct(object): + + def __init__(self): + """Constructor.""" + # Initialize + self.layer_data = self._get_layer_data() + self.layer_data.products = self.get_colorspace_data() + + def _get_layer_data(self): + scene = bpy.context.scene + + return LayerMetadata( + frameStart=int(scene.frame_start), + frameEnd=int(scene.frame_end), + ) + + def get_colorspace_data(self): + """To be implemented by renderer class. + This should return a list of RenderProducts. + Returns: + list: List of RenderProduct + """ + return [ + RenderProduct( + colorspace="sRGB", + view="ACES 1.0", + productName="" + ) + ] diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index cd3b922697..becb735c21 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -12,10 +12,7 @@ from openpype.pipeline import ( from openpype.settings import ( get_project_settings, ) -from openpype.hosts.blender.api.ops import ( - MainThreadItem, - execute_in_main_thread -) +from openpype.hosts.blender.api import colorspace import pyblish.api @@ -73,12 +70,13 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): return render_product @staticmethod - def generate_expected_files( - render_product, frame_start, frame_end, frame_step + def generate_expected_beauty( + render_product, frame_start, frame_end, frame_step, ext ): - """Generate the expected files for the render product. - This returns a list of files that should be rendered. It replaces - the sequence of `#` with the frame number. + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. """ path = os.path.dirname(render_product) file = os.path.basename(render_product) @@ -87,9 +85,39 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): for frame in range(frame_start, frame_end + 1, frame_step): frame_str = str(frame).rjust(4, "0") - expected_file = os.path.join(path, re.sub("#+", frame_str, file)) + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" expected_files.append(expected_file.replace("\\", "/")) + return { + "beauty": expected_files + } + + @staticmethod + def generate_expected_aovs( + aov_file_product, frame_start, frame_end, frame_step, ext + ): + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. + """ + expected_files = {} + + for aov_name, aov_file in aov_file_product: + path = os.path.dirname(aov_file) + file = os.path.basename(aov_file) + + aov_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" + aov_files.append(expected_file.replace("\\", "/")) + + expected_files[aov_name] = aov_files + return expected_files @staticmethod @@ -117,7 +145,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): elif ext == "tif": image_settings.file_format = "TIFF" - def _create_context(): + def _create_context(self): context = bpy.context.copy() win = bpy.context.window_manager.windows[0] @@ -132,7 +160,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): return context - def _set_node_tree(self, output_path, instance): + def set_node_tree(self, output_path, instance): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True @@ -153,8 +181,9 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # render. # We also exclude some layers. exclude_sockets = ["Image", "Alpha"] - passes = [ - socket for socket in rl_node.outputs + passes = [ + socket + for socket in rl_node.outputs if socket.enabled and socket.name not in exclude_sockets ] @@ -182,11 +211,16 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format + aov_file_products = [] + # For each active render pass, we add a new socket to the output node # and link it for render_pass in passes: - bpy.ops.node.output_file_add_socket( - context, file_path=f"{instance.name}_{render_pass.name}.####") + filepath = f"{instance.name}_{render_pass.name}.####" + bpy.ops.node.output_file_add_socket(context, file_path=filepath) + + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) node_input = output.inputs[-1] @@ -195,10 +229,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # Restore the area type area.ui_type = old_area_type - def set_node_tree(self, output_path, instance): - """ Run the creator on Blender main thread""" - mti = MainThreadItem(self._set_node_tree, output_path, instance) - execute_in_main_thread(mti) + return aov_file_products @staticmethod def set_render_camera(instance): @@ -230,7 +261,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): output_path = os.path.join(file_path, render_folder, file_name) render_product = self.get_render_product(output_path, instance) - self.set_node_tree(output_path, instance) + aov_file_product = self.set_node_tree(output_path, instance) # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product @@ -245,9 +276,15 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): frame_handle_start = context.data["frameStartHandle"] frame_handle_end = context.data["frameEndHandle"] - expected_files = self.generate_expected_files( + expected_beauty = self.generate_expected_beauty( render_product, int(frame_start), int(frame_end), - int(bpy.context.scene.frame_step)) + int(bpy.context.scene.frame_step), ext) + + expected_aovs = self.generate_expected_aovs( + aov_file_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step), ext) + + expected_files = expected_beauty | expected_aovs instance.data.update({ "frameStart": frame_start, @@ -257,7 +294,14 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): "fps": context.data["fps"], "byFrameStep": bpy.context.scene.frame_step, "farm": True, - "expectedFiles": expected_files, + "expectedFiles": [expected_files], + # OCIO not currently implemented in Blender, but the following + # settings are required by the schema, so it is hardcoded. + # TODO: Implement OCIO in Blender + "colorspaceConfig": "", + "colorspaceDisplay": "sRGB", + "colorspaceView": "ACES 1.0 SDR-video", + "renderProducts": colorspace.ARenderProduct(), }) self.log.info(f"data: {instance.data}") diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 9bfc4fbb07..ad456c0d13 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -163,7 +163,8 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): if not expected_files: raise RuntimeError("No Render Elements found!") - output_dir = os.path.dirname(expected_files[0]) + first_file = next(iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir instance.data["toBeRenderedOn"] = "deadline" From c9f5a9743257b8ce242660da473d69cb8dba8b52 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 22 Aug 2023 16:33:51 +0100 Subject: [PATCH 012/327] Added setting for aov separator --- .../blender/plugins/publish/collect_render.py | 26 ++++++++++++++++--- .../defaults/project_settings/blender.json | 1 + .../schema_project_blender.json | 12 +++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index becb735c21..b16354460a 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -33,6 +33,23 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): ["RenderSettings"] ["default_render_image_folder"]) + @staticmethod + def get_aov_separator(settings): + """Get aov separator from blender settings.""" + + aov_sep = (settings["blender"] + ["RenderSettings"] + ["aov_separator"]) + + if aov_sep == "dash": + return "-" + elif aov_sep == "underscore": + return "_" + elif aov_sep == "dot": + return "." + else: + raise ValueError(f"Invalid aov separator: {aov_sep}") + @staticmethod def get_image_format(settings): """Get image format from blender settings.""" @@ -160,7 +177,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): return context - def set_node_tree(self, output_path, instance): + def set_node_tree(self, output_path, instance, aov_sep): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True @@ -181,7 +198,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # render. # We also exclude some layers. exclude_sockets = ["Image", "Alpha"] - passes = [ + passes = [ socket for socket in rl_node.outputs if socket.enabled and socket.name not in exclude_sockets @@ -216,7 +233,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # For each active render pass, we add a new socket to the output node # and link it for render_pass in passes: - filepath = f"{instance.name}_{render_pass.name}.####" + filepath = f"{instance.name}{aov_sep}{render_pass.name}.####" bpy.ops.node.output_file_add_socket(context, file_path=filepath) aov_file_products.append( @@ -255,13 +272,14 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): settings = get_project_settings(project) render_folder = self.get_default_render_folder(settings) + aov_sep = self.get_aov_separator(settings) ext = self.get_image_format(settings) multilayer = self.get_multilayer(settings) output_path = os.path.join(file_path, render_folder, file_name) render_product = self.get_render_product(output_path, instance) - aov_file_product = self.set_node_tree(output_path, instance) + aov_file_product = self.set_node_tree(output_path, instance, aov_sep) # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index c375e550c2..17387f4db6 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -19,6 +19,7 @@ }, "RenderSettings": { "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", "image_format": "exr", "multilayer_exr": true }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index d8ef1eee3e..ecad74b621 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -65,6 +65,18 @@ "key": "default_render_image_folder", "label": "Default render image folder" }, + { + "key": "aov_separator", + "label": "AOV Separator Character", + "type": "enum", + "multiselection": false, + "defaults": "underscore", + "enum_items": [ + {"dash": "- (dash)"}, + {"underscore": "_ (underscore)"}, + {"dot": ". (dot)"} + ] + }, { "key": "image_format", "label": "Output Image Format", From 5f901f2a62d781d6f29dd145fe69bbc26da24651 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 22 Aug 2023 17:38:33 +0100 Subject: [PATCH 013/327] Added setting to set AOVs --- .../blender/plugins/publish/collect_render.py | 25 +++++++++++++++++++ .../defaults/project_settings/blender.json | 3 ++- .../schema_project_blender.json | 23 +++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index b16354460a..309d40d9fd 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -162,6 +162,29 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): elif ext == "tif": image_settings.file_format = "TIFF" + @staticmethod + def set_render_passes(settings): + aov_list = (settings["blender"] + ["RenderSettings"] + ["aov_list"]) + + scene = bpy.context.scene + vl = bpy.context.view_layer + + vl.use_pass_combined = "combined" in aov_list + vl.use_pass_z = "z" in aov_list + vl.use_pass_mist = "mist" in aov_list + vl.use_pass_normal = "normal" in aov_list + vl.use_pass_diffuse_direct = "diffuse_light" in aov_list + vl.use_pass_diffuse_color = "diffuse_color" in aov_list + vl.use_pass_glossy_direct = "specular_light" in aov_list + vl.use_pass_glossy_color = "specular_color" in aov_list + vl.eevee.use_pass_volume_direct = "volume_light" in aov_list + vl.use_pass_emit = "emission" in aov_list + vl.use_pass_environment = "environment" in aov_list + vl.use_pass_shadow = "shadow" in aov_list + vl.use_pass_ambient_occlusion = "ao" in aov_list + def _create_context(self): context = bpy.context.copy() @@ -276,6 +299,8 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): ext = self.get_image_format(settings) multilayer = self.get_multilayer(settings) + self.set_render_passes(settings) + output_path = os.path.join(file_path, render_folder, file_name) render_product = self.get_render_product(output_path, instance) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 17387f4db6..d36fc503dd 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -21,7 +21,8 @@ "default_render_image_folder": "renders/blender", "aov_separator": "underscore", "image_format": "exr", - "multilayer_exr": true + "multilayer_exr": true, + "aov_list": [] }, "workfile_builder": { "create_first_version": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index ecad74b621..b7d61d1d69 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -98,6 +98,29 @@ "key": "multilayer_exr", "type": "boolean", "label": "Multilayer (EXR)" + }, + { + "key": "aov_list", + "label": "AOVs to create", + "type": "enum", + "multiselection": true, + "defaults": "empty", + "enum_items": [ + {"empty": "< empty >"}, + {"combined": "Combined"}, + {"z": "Z"}, + {"mist": "Mist"}, + {"normal": "Normal"}, + {"diffuse_light": "Diffuse Light"}, + {"diffuse_color": "Diffuse Color"}, + {"specular_light": "Specular Light"}, + {"specular_color": "Specular Color"}, + {"volume_light": "Volume Light"}, + {"emission": "Emission"}, + {"environment": "Environment"}, + {"shadow": "Shadow"}, + {"ao": "Ambient Occlusion"} + ] } ] }, From fc1a98b47173d34843b0e2b59ca6e492763700d0 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 09:32:32 +0100 Subject: [PATCH 014/327] Added support for custom render passes --- .../blender/plugins/publish/collect_render.py | 15 ++++++++++- .../defaults/project_settings/blender.json | 3 ++- .../schema_project_blender.json | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 309d40d9fd..7c95bb14cf 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -168,7 +168,10 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): ["RenderSettings"] ["aov_list"]) - scene = bpy.context.scene + custom_passes = (settings["blender"] + ["RenderSettings"] + ["custom_passes"]) + vl = bpy.context.view_layer vl.use_pass_combined = "combined" in aov_list @@ -185,6 +188,16 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): vl.use_pass_shadow = "shadow" in aov_list vl.use_pass_ambient_occlusion = "ao" in aov_list + aovs_names = [aov.name for aov in vl.aovs] + for cp in custom_passes: + cp_name = cp[0] + if cp_name not in aovs_names: + aov = vl.aovs.add() + aov.name = cp_name + else: + aov = vl.aovs[cp_name] + aov.type = cp[1].get("type", "VALUE") + def _create_context(self): context = bpy.context.copy() diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index d36fc503dd..8b1d602df0 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -22,7 +22,8 @@ "aov_separator": "underscore", "image_format": "exr", "multilayer_exr": true, - "aov_list": [] + "aov_list": [], + "custom_passes": [] }, "workfile_builder": { "create_first_version": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index b7d61d1d69..8db57f49eb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -121,6 +121,33 @@ {"shadow": "Shadow"}, {"ao": "Ambient Occlusion"} ] + }, + { + "type": "label", + "label": "Add custom AOVs. They are added to the view layer and in the Compositing Nodetree,\nbut they need to be added manually to the Shader Nodetree." + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "custom_passes", + "label": "Custom Passes", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "type", + "label": "Type", + "type": "enum", + "multiselection": false, + "defaults": "color", + "enum_items": [ + {"COLOR": "Color"}, + {"VALUE": "Value"} + ] + } + ] + } } ] }, From 8f78ebeabdb1e6e7fdf0ab461d8b609669266c29 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 10:51:01 +0100 Subject: [PATCH 015/327] Fixed problem with blender context and multilayer exr --- .../blender/plugins/publish/collect_render.py | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 7c95bb14cf..2622c51432 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -198,22 +198,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): aov = vl.aovs[cp_name] aov.type = cp[1].get("type", "VALUE") - def _create_context(self): - context = bpy.context.copy() - - win = bpy.context.window_manager.windows[0] - screen = win.screen - area = screen.areas[0] - region = area.regions[0] - - context["window"] = win - context['screen'] = screen - context['area'] = area - context['region'] = region - - return context - - def set_node_tree(self, output_path, instance, aov_sep): + def set_node_tree(self, output_path, instance, aov_sep, ext, multilayer): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True @@ -248,17 +233,10 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # Create a new output node output = tree.nodes.new("CompositorNodeOutputFile") - context = self._create_context() - context["node"] = output - - area = context["area"] - - # Change area type to node editor, to execute node operators - old_area_type = area.ui_type - area.ui_type = "CompositorNodeTree" - - # Remove the default input socket from the output node - bpy.ops.node.output_file_remove_active_socket(context) + if ext == "exr" and multilayer: + output.layer_slots.clear() + else: + output.file_slots.clear() output.base_path = output_path image_settings = bpy.context.scene.render.image_settings @@ -270,18 +248,18 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): # and link it for render_pass in passes: filepath = f"{instance.name}{aov_sep}{render_pass.name}.####" - bpy.ops.node.output_file_add_socket(context, file_path=filepath) + if ext == "exr" and multilayer: + output.layer_slots.new(render_pass.name) + else: + output.file_slots.new(filepath) - aov_file_products.append( - (render_pass.name, os.path.join(output_path, filepath))) + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) node_input = output.inputs[-1] tree.links.new(render_pass, node_input) - # Restore the area type - area.ui_type = old_area_type - return aov_file_products @staticmethod @@ -317,7 +295,8 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): output_path = os.path.join(file_path, render_folder, file_name) render_product = self.get_render_product(output_path, instance) - aov_file_product = self.set_node_tree(output_path, instance, aov_sep) + aov_file_product = self.set_node_tree( + output_path, instance, aov_sep, ext, multilayer) # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product From c529abb8ab383ff50e92b8b0284ff2efa2fbff63 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 15:39:31 +0100 Subject: [PATCH 016/327] Moved most of the logic in the creator --- .../blender/plugins/create/create_render.py | 267 +++++++++++++++++- .../blender/plugins/publish/collect_render.py | 235 +-------------- 2 files changed, 266 insertions(+), 236 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 8323b88cfe..49d356ab67 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -1,9 +1,18 @@ """Create render.""" +import os +import re import bpy +from openpype.pipeline import ( + get_current_context, + get_current_project_name, +) +from openpype.settings import ( + get_project_settings, +) from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -15,14 +24,217 @@ class CreateRenderlayer(plugin.Creator): family = "renderlayer" icon = "eye" - render_settings = {} + @staticmethod + def get_default_render_folder(settings): + """Get default render folder from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["default_render_image_folder"]) + + @staticmethod + def get_aov_separator(settings): + """Get aov separator from blender settings.""" + + aov_sep = (settings["blender"] + ["RenderSettings"] + ["aov_separator"]) + + if aov_sep == "dash": + return "-" + elif aov_sep == "underscore": + return "_" + elif aov_sep == "dot": + return "." + else: + raise ValueError(f"Invalid aov separator: {aov_sep}") + + @staticmethod + def get_image_format(settings): + """Get image format from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["image_format"]) + + @staticmethod + def get_multilayer(settings): + """Get multilayer from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["multilayer_exr"]) + + @staticmethod + def get_render_product(output_path, name): + """ + Generate the path to the render product. Blender interprets the `#` + as the frame number, when it renders. + + Args: + file_path (str): The path to the blender scene. + render_folder (str): The render folder set in settings. + file_name (str): The name of the blender scene. + instance (pyblish.api.Instance): The instance to publish. + ext (str): The image format to render. + """ + output_file = os.path.join(output_path, name) + + render_product = f"{output_file}.####" + render_product = render_product.replace("\\", "/") + + return render_product + + @staticmethod + def set_render_format(ext, multilayer): + # Set Blender to save the file with the right extension + bpy.context.scene.render.use_file_extension = True + + image_settings = bpy.context.scene.render.image_settings + + if ext == "exr": + image_settings.file_format = ( + "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") + elif ext == "bmp": + image_settings.file_format = "BMP" + elif ext == "rgb": + image_settings.file_format = "IRIS" + elif ext == "png": + image_settings.file_format = "PNG" + elif ext == "jpeg": + image_settings.file_format = "JPEG" + elif ext == "jp2": + image_settings.file_format = "JPEG2000" + elif ext == "tga": + image_settings.file_format = "TARGA" + elif ext == "tif": + image_settings.file_format = "TIFF" + + @staticmethod + def set_render_passes(settings): + aov_list = (settings["blender"] + ["RenderSettings"] + ["aov_list"]) + + custom_passes = (settings["blender"] + ["RenderSettings"] + ["custom_passes"]) + + vl = bpy.context.view_layer + + vl.use_pass_combined = "combined" in aov_list + vl.use_pass_z = "z" in aov_list + vl.use_pass_mist = "mist" in aov_list + vl.use_pass_normal = "normal" in aov_list + vl.use_pass_diffuse_direct = "diffuse_light" in aov_list + vl.use_pass_diffuse_color = "diffuse_color" in aov_list + vl.use_pass_glossy_direct = "specular_light" in aov_list + vl.use_pass_glossy_color = "specular_color" in aov_list + vl.eevee.use_pass_volume_direct = "volume_light" in aov_list + vl.use_pass_emit = "emission" in aov_list + vl.use_pass_environment = "environment" in aov_list + vl.use_pass_shadow = "shadow" in aov_list + vl.use_pass_ambient_occlusion = "ao" in aov_list + + aovs_names = [aov.name for aov in vl.aovs] + for cp in custom_passes: + cp_name = cp[0] + if cp_name not in aovs_names: + aov = vl.aovs.add() + aov.name = cp_name + else: + aov = vl.aovs[cp_name] + aov.type = cp[1].get("type", "VALUE") + + return aov_list, custom_passes + + def set_node_tree(self, output_path, name, aov_sep, ext, multilayer): + # Set the scene to use the compositor node tree to render + bpy.context.scene.use_nodes = True + + tree = bpy.context.scene.node_tree + + # Get the Render Layers node + rl_node = None + for node in tree.nodes: + if node.bl_idname == "CompositorNodeRLayers": + rl_node = node + break + + # If there's not a Render Layers node, we create it + if not rl_node: + rl_node = tree.nodes.new("CompositorNodeRLayers") + + # Get the enabled output sockets, that are the active passes for the + # render. + # We also exclude some layers. + exclude_sockets = ["Image", "Alpha"] + passes = [ + socket + for socket in rl_node.outputs + if socket.enabled and socket.name not in exclude_sockets + ] + + # Remove all output nodes + for node in tree.nodes: + if node.bl_idname == "CompositorNodeOutputFile": + tree.nodes.remove(node) + + # Create a new output node + output = tree.nodes.new("CompositorNodeOutputFile") + + if ext == "exr" and multilayer: + output.layer_slots.clear() + else: + output.file_slots.clear() + + output.base_path = output_path + image_settings = bpy.context.scene.render.image_settings + output.format.file_format = image_settings.file_format + + aov_file_products = [] + + # For each active render pass, we add a new socket to the output node + # and link it + for render_pass in passes: + filepath = f"{name}{aov_sep}{render_pass.name}.####" + if ext == "exr" and multilayer: + output.layer_slots.new(render_pass.name) + else: + output.file_slots.new(filepath) + + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) + + node_input = output.inputs[-1] + + tree.links.new(render_pass, node_input) + + return aov_file_products + + @staticmethod + def set_render_camera(asset_group): + # There should be only one camera in the instance + found = False + for obj in asset_group.all_objects: + if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA": + bpy.context.scene.camera = obj + found = True + break + + assert found, "No camera found in the render instance" + + @staticmethod + def imprint_render_settings(node, data): + RENDER_DATA = "render_data" + if not node.get(RENDER_DATA): + node[RENDER_DATA] = {} + for key, value in data.items(): + if value is None: + continue + node[RENDER_DATA][key] = value def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) - - def _process(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: @@ -46,4 +258,45 @@ class CreateRenderlayer(plugin.Creator): obj = (self.options or {}).get("asset_group") asset_group.objects.link(obj) + filepath = bpy.data.filepath + assert filepath, "Workfile not saved. Please save the file first." + + file_path = os.path.dirname(filepath) + file_name = os.path.basename(filepath) + file_name, _ = os.path.splitext(file_name) + + project = get_current_project_name() + settings = get_project_settings(project) + + render_folder = self.get_default_render_folder(settings) + aov_sep = self.get_aov_separator(settings) + ext = self.get_image_format(settings) + multilayer = self.get_multilayer(settings) + + aov_list, custom_passes = self.set_render_passes(settings) + + output_path = os.path.join(file_path, render_folder, file_name) + + render_product = self.get_render_product(output_path, name) + aov_file_product = self.set_node_tree( + output_path, name, aov_sep, ext, multilayer) + + # We set the render path, the format and the camera + bpy.context.scene.render.filepath = render_product + self.set_render_format(ext, multilayer) + self.set_render_camera(asset_group) + + render_settings = { + "render_folder": render_folder, + "aov_separator": aov_sep, + "image_format": ext, + "multilayer_exr": multilayer, + "aov_list": aov_list, + "custom_passes": custom_passes, + "render_product": render_product, + "aov_file_product": aov_file_product, + } + + self.imprint_render_settings(asset_group, render_settings) + return asset_group diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 2622c51432..557a4c9066 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -6,12 +6,6 @@ import re import bpy -from openpype.pipeline import ( - get_current_project_name, -) -from openpype.settings import ( - get_project_settings, -) from openpype.hosts.blender.api import colorspace import pyblish.api @@ -25,67 +19,6 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): label = "Collect Render Layers" sync_workfile_version = False - @staticmethod - def get_default_render_folder(settings): - """Get default render folder from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["default_render_image_folder"]) - - @staticmethod - def get_aov_separator(settings): - """Get aov separator from blender settings.""" - - aov_sep = (settings["blender"] - ["RenderSettings"] - ["aov_separator"]) - - if aov_sep == "dash": - return "-" - elif aov_sep == "underscore": - return "_" - elif aov_sep == "dot": - return "." - else: - raise ValueError(f"Invalid aov separator: {aov_sep}") - - @staticmethod - def get_image_format(settings): - """Get image format from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["image_format"]) - - @staticmethod - def get_multilayer(settings): - """Get multilayer from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["multilayer_exr"]) - - @staticmethod - def get_render_product(output_path, instance): - """ - Generate the path to the render product. Blender interprets the `#` - as the frame number, when it renders. - - Args: - file_path (str): The path to the blender scene. - render_folder (str): The render folder set in settings. - file_name (str): The name of the blender scene. - instance (pyblish.api.Instance): The instance to publish. - ext (str): The image format to render. - """ - output_file = os.path.join(output_path, instance.name) - - render_product = f"{output_file}.####" - render_product = render_product.replace("\\", "/") - - return render_product - @staticmethod def generate_expected_beauty( render_product, frame_start, frame_end, frame_step, ext @@ -137,174 +70,18 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): return expected_files - @staticmethod - def set_render_format(ext, multilayer): - # Set Blender to save the file with the right extension - bpy.context.scene.render.use_file_extension = True - - image_settings = bpy.context.scene.render.image_settings - - if ext == "exr": - image_settings.file_format = ( - "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") - elif ext == "bmp": - image_settings.file_format = "BMP" - elif ext == "rgb": - image_settings.file_format = "IRIS" - elif ext == "png": - image_settings.file_format = "PNG" - elif ext == "jpeg": - image_settings.file_format = "JPEG" - elif ext == "jp2": - image_settings.file_format = "JPEG2000" - elif ext == "tga": - image_settings.file_format = "TARGA" - elif ext == "tif": - image_settings.file_format = "TIFF" - - @staticmethod - def set_render_passes(settings): - aov_list = (settings["blender"] - ["RenderSettings"] - ["aov_list"]) - - custom_passes = (settings["blender"] - ["RenderSettings"] - ["custom_passes"]) - - vl = bpy.context.view_layer - - vl.use_pass_combined = "combined" in aov_list - vl.use_pass_z = "z" in aov_list - vl.use_pass_mist = "mist" in aov_list - vl.use_pass_normal = "normal" in aov_list - vl.use_pass_diffuse_direct = "diffuse_light" in aov_list - vl.use_pass_diffuse_color = "diffuse_color" in aov_list - vl.use_pass_glossy_direct = "specular_light" in aov_list - vl.use_pass_glossy_color = "specular_color" in aov_list - vl.eevee.use_pass_volume_direct = "volume_light" in aov_list - vl.use_pass_emit = "emission" in aov_list - vl.use_pass_environment = "environment" in aov_list - vl.use_pass_shadow = "shadow" in aov_list - vl.use_pass_ambient_occlusion = "ao" in aov_list - - aovs_names = [aov.name for aov in vl.aovs] - for cp in custom_passes: - cp_name = cp[0] - if cp_name not in aovs_names: - aov = vl.aovs.add() - aov.name = cp_name - else: - aov = vl.aovs[cp_name] - aov.type = cp[1].get("type", "VALUE") - - def set_node_tree(self, output_path, instance, aov_sep, ext, multilayer): - # Set the scene to use the compositor node tree to render - bpy.context.scene.use_nodes = True - - tree = bpy.context.scene.node_tree - - # Get the Render Layers node - rl_node = None - for node in tree.nodes: - if node.bl_idname == "CompositorNodeRLayers": - rl_node = node - break - - # If there's not a Render Layers node, we create it - if not rl_node: - rl_node = tree.nodes.new("CompositorNodeRLayers") - - # Get the enabled output sockets, that are the active passes for the - # render. - # We also exclude some layers. - exclude_sockets = ["Image", "Alpha"] - passes = [ - socket - for socket in rl_node.outputs - if socket.enabled and socket.name not in exclude_sockets - ] - - # Remove all output nodes - for node in tree.nodes: - if node.bl_idname == "CompositorNodeOutputFile": - tree.nodes.remove(node) - - # Create a new output node - output = tree.nodes.new("CompositorNodeOutputFile") - - if ext == "exr" and multilayer: - output.layer_slots.clear() - else: - output.file_slots.clear() - - output.base_path = output_path - image_settings = bpy.context.scene.render.image_settings - output.format.file_format = image_settings.file_format - - aov_file_products = [] - - # For each active render pass, we add a new socket to the output node - # and link it - for render_pass in passes: - filepath = f"{instance.name}{aov_sep}{render_pass.name}.####" - if ext == "exr" and multilayer: - output.layer_slots.new(render_pass.name) - else: - output.file_slots.new(filepath) - - aov_file_products.append( - (render_pass.name, os.path.join(output_path, filepath))) - - node_input = output.inputs[-1] - - tree.links.new(render_pass, node_input) - - return aov_file_products - - @staticmethod - def set_render_camera(instance): - # There should be only one camera in the instance - found = False - for obj in instance: - if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA": - bpy.context.scene.camera = obj - found = True - break - - assert found, "No camera found in the render instance" - def process(self, instance): context = instance.context - filepath = context.data["currentFile"].replace("\\", "/") - file_path = os.path.dirname(filepath) - file_name = os.path.basename(filepath) - file_name, _ = os.path.splitext(file_name) + render_data = bpy.data.collections[str(instance)].get("render_data") - project = get_current_project_name() - settings = get_project_settings(project) + assert render_data, "No render data found." - render_folder = self.get_default_render_folder(settings) - aov_sep = self.get_aov_separator(settings) - ext = self.get_image_format(settings) - multilayer = self.get_multilayer(settings) + self.log.info(f"render_data: {dict(render_data)}") - self.set_render_passes(settings) - - output_path = os.path.join(file_path, render_folder, file_name) - - render_product = self.get_render_product(output_path, instance) - aov_file_product = self.set_node_tree( - output_path, instance, aov_sep, ext, multilayer) - - # We set the render path, the format and the camera - bpy.context.scene.render.filepath = render_product - self.set_render_format(ext, multilayer) - self.set_render_camera(instance) - - # We save the file to save the render settings - bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + render_product = render_data.get("render_product") + aov_file_product = render_data.get("aov_file_product") + ext = render_data.get("image_format") frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] From 259255e7df5bfa6511c859d064aaea0d420b2f88 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 15:40:28 +0100 Subject: [PATCH 017/327] Hound fixes --- openpype/hosts/blender/plugins/create/create_render.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 49d356ab67..53a84ab0b8 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -1,11 +1,9 @@ """Create render.""" import os -import re import bpy from openpype.pipeline import ( - get_current_context, get_current_project_name, ) from openpype.settings import ( From 089909192026a82fde87ebe80e54871c712e32af Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 16:33:02 +0100 Subject: [PATCH 018/327] Fix EXR multilayer output --- openpype/hosts/blender/plugins/create/create_render.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 53a84ab0b8..468e4024e9 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -183,10 +183,12 @@ class CreateRenderlayer(plugin.Creator): if ext == "exr" and multilayer: output.layer_slots.clear() + filepath = f"{name}{aov_sep}AOVs.####" + output.base_path = os.path.join(output_path, filepath) else: output.file_slots.clear() + output.base_path = output_path - output.base_path = output_path image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format @@ -195,10 +197,11 @@ class CreateRenderlayer(plugin.Creator): # For each active render pass, we add a new socket to the output node # and link it for render_pass in passes: - filepath = f"{name}{aov_sep}{render_pass.name}.####" if ext == "exr" and multilayer: output.layer_slots.new(render_pass.name) else: + filepath = f"{name}{aov_sep}{render_pass.name}.####" + output.file_slots.new(filepath) aov_file_products.append( From 028d15fc1efadd93fc963eae52177efa12d66d49 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 23 Aug 2023 16:55:31 +0100 Subject: [PATCH 019/327] Added validator to check if file is saved --- .../plugins/publish/validate_file_saved.py | 20 +++++++++++ .../defaults/project_settings/blender.json | 6 ++++ .../schemas/schema_blender_publish.json | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 openpype/hosts/blender/plugins/publish/validate_file_saved.py diff --git a/openpype/hosts/blender/plugins/publish/validate_file_saved.py b/openpype/hosts/blender/plugins/publish/validate_file_saved.py new file mode 100644 index 0000000000..e191585c55 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_file_saved.py @@ -0,0 +1,20 @@ +import bpy + +import pyblish.api + + +class ValidateFileSaved(pyblish.api.InstancePlugin): + """Validate that the workfile has been saved.""" + + order = pyblish.api.ValidatorOrder - 0.01 + hosts = ["blender"] + label = "Validate File Saved" + optional = False + exclude_families = [] + + def process(self, instance): + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + if bpy.data.is_dirty: + raise RuntimeError("Workfile is not saved.") diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 8b1d602df0..09ed800ac8 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -35,6 +35,12 @@ "optional": true, "active": true }, + "ValidateFileSaved": { + "enabled": true, + "optional": false, + "active": true, + "exclude_families": [] + }, "ValidateMeshHasUvs": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 2f0bf0a831..0b694e5b70 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -18,6 +18,39 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateFileSaved", + "label": "Validate File Saved", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "splitter" + }, + { + "key": "exclude_families", + "label": "Exclude Families", + "type": "list", + "object_type": "text" + } + ] + }, { "type": "collapsible-wrap", "label": "Model", From b6e9806258086f260c604ee1ad5a932d93e85b70 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Aug 2023 22:39:13 +0200 Subject: [PATCH 020/327] Allow duplicating publish instance by defining `instance_node` and `instance_id` from `node.path()` instead of parms. --- openpype/hosts/houdini/api/plugin.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 70c837205e..c3fd313a0b 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -187,13 +187,14 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.customize_node_look(instance_node) instance_data["instance_node"] = instance_node.path() + instance_data["instance_id"] = instance_node.path() instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) - imprint(instance_node, instance.data_to_store()) + self.imprint(instance_node, instance.data_to_store()) return instance except hou.Error as er: @@ -222,25 +223,41 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "houdini_cached_subsets"].get(self.identifier, []): + + node_data = read(instance) + + # Node paths are always the full node path since that is unique + # Because it's the node's path it's not written into attributes + # but explicitly collected + node_path = instance.path() + node_data["instance_id"] = node_path + node_data["instance_node"] = node_path + created_instance = CreatedInstance.from_existing( - read(instance), self + node_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) - new_values = { key: changes[key].new_value for key in changes.changed_keys } - imprint( + self.imprint( instance_node, new_values, update=True ) + def imprint(self, node, values, update=False): + # Never store instance node and instance id since that data comes + # from the node's path + values.pop("instance_node", None) + values.pop("instance_id", None) + imprint(node, values, update=update) + def remove_instances(self, instances): """Remove specified instance from the scene. From 5ff66afff7b91b1d1583be28c0da99cc088a3300 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Aug 2023 22:40:25 +0200 Subject: [PATCH 021/327] Allow duplicating publish instances in Maya by not storin instance id as an attribute but using the (unique) node's name instead. --- openpype/hosts/maya/api/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 00d6602ef9..00a2c899a2 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -134,6 +134,7 @@ class MayaCreatorBase(object): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) + data.pop("instance_id", None) # We store creator attributes at the root level and assume they # will not clash in names with `subset`, `task`, etc. and other @@ -185,6 +186,7 @@ class MayaCreatorBase(object): # Explicitly re-parse the node name node_data["instance_node"] = node + node_data["instance_id"] = node return node_data From 6da94d4f27be41a974b8f6348c45060510f74032 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Aug 2023 22:41:25 +0200 Subject: [PATCH 022/327] Allow to duplicate publish instances in Fusion, by not relying on `instance_id` data but have the unique identifier be the node's name. --- openpype/hosts/fusion/plugins/create/create_saver.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..590a678a3d 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -127,6 +127,9 @@ class CreateSaver(NewCreator): def _imprint(self, tool, data): # Save all data in a "openpype.{key}" = value data + # Instance id is the tool's name so we don't need to imprint as data + data.pop("instance_id", None) + active = data.pop("active", None) if active is not None: # Use active value to set the passthrough state @@ -192,6 +195,10 @@ class CreateSaver(NewCreator): passthrough = attrs["TOOLB_PassThrough"] data["active"] = not passthrough + # Override publisher's UUID generation because tool names are + # already unique in Fusion in a comp + data["instance_id"] = tool.Name + return data def get_pre_create_attr_defs(self): From a0c25edab9e3f209486a53b8caa99b658366f9c7 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 24 Aug 2023 11:06:37 +0100 Subject: [PATCH 023/327] Add check to remove instance if any error is triggered during creation --- .../blender/plugins/create/create_render.py | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 468e4024e9..63093b539c 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -235,30 +235,7 @@ class CreateRenderlayer(plugin.Creator): continue node[RENDER_DATA][key] = value - def process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.collections.new(name=name) - instances.children.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - if (self.options or {}).get("useSelection"): - selected = lib.get_selection() - for obj in selected: - asset_group.objects.link(obj) - elif (self.options or {}).get("asset_group"): - obj = (self.options or {}).get("asset_group") - asset_group.objects.link(obj) - + def prepare_rendering(self, asset_group, name): filepath = bpy.data.filepath assert filepath, "Workfile not saved. Please save the file first." @@ -300,4 +277,28 @@ class CreateRenderlayer(plugin.Creator): self.imprint_render_settings(asset_group, render_settings) + def process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.collections.new(name=name) + + try: + instances.children.link(asset_group) + self.data['task'] = get_current_task_name() + lib.imprint(asset_group, self.data) + + self.prepare_rendering(asset_group, name) + except Exception: + # Remove the instance if there was an error + bpy.data.collections.remove(asset_group) + raise + return asset_group From 153a2999011f27901dd05a8953cd91ea82b649c1 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 24 Aug 2023 11:37:52 +0100 Subject: [PATCH 024/327] Removed camera from render instance and added validator to check camera --- .../blender/plugins/create/create_render.py | 13 ---------- .../publish/validate_render_camera_is_set.py | 17 +++++++++++++ .../defaults/project_settings/blender.json | 5 ++++ .../schemas/schema_blender_publish.json | 24 +++++++++++++++++++ 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 63093b539c..2952baafd3 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -213,18 +213,6 @@ class CreateRenderlayer(plugin.Creator): return aov_file_products - @staticmethod - def set_render_camera(asset_group): - # There should be only one camera in the instance - found = False - for obj in asset_group.all_objects: - if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA": - bpy.context.scene.camera = obj - found = True - break - - assert found, "No camera found in the render instance" - @staticmethod def imprint_render_settings(node, data): RENDER_DATA = "render_data" @@ -262,7 +250,6 @@ class CreateRenderlayer(plugin.Creator): # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product self.set_render_format(ext, multilayer) - self.set_render_camera(asset_group) render_settings = { "render_folder": render_folder, diff --git a/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py new file mode 100644 index 0000000000..5a06c1ff0a --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py @@ -0,0 +1,17 @@ +import bpy + +import pyblish.api + + +class ValidateRenderCameraIsSet(pyblish.api.InstancePlugin): + """Validate that there is a camera set as active for rendering.""" + + order = pyblish.api.ValidatorOrder + hosts = ["blender"] + families = ["renderlayer"] + label = "Validate Render Camera Is Set" + optional = False + + def process(self, instance): + if not bpy.context.scene.camera: + raise RuntimeError("No camera is active for rendering.") diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 09ed800ac8..9cbbb49593 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -41,6 +41,11 @@ "active": true, "exclude_families": [] }, + "ValidateRenderCameraIsSet": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateMeshHasUvs": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 0b694e5b70..05e7f13e70 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -51,6 +51,30 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderCameraIsSet", + "label": "Validate Render Camera Is Set", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + }, { "type": "collapsible-wrap", "label": "Model", From 0f1bf31f69bfb0632eba5f8994e84c6f72d52126 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 24 Aug 2023 15:33:16 +0100 Subject: [PATCH 025/327] Updated settings for Ayon --- server_addon/blender/server/settings/main.py | 7 ++ .../server/settings/publish_plugins.py | 31 +++++ .../server/settings/render_settings.py | 106 ++++++++++++++++++ server_addon/blender/server/version.py | 2 +- .../server/settings/publish_plugins.py | 25 ++++- 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 server_addon/blender/server/settings/render_settings.py diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index f6118d39cd..4476ea709b 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -9,6 +9,10 @@ from .publish_plugins import ( PublishPuginsModel, DEFAULT_BLENDER_PUBLISH_SETTINGS ) +from .render_settings import ( + RenderSettingsModel, + DEFAULT_RENDER_SETTINGS +) class UnitScaleSettingsModel(BaseSettingsModel): @@ -37,6 +41,8 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) + render_settings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, title="Workfile Builder" @@ -55,6 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, + "render_settings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 65dda78411..575bfe9f39 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -26,6 +26,16 @@ class ValidatePluginModel(BaseSettingsModel): active: bool = Field(title="Active") +class ValidateFileSavedModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateFileSaved") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + exclude_families: list[str] = Field( + default_factory=list, + title="Exclude product types" + ) + + class ExtractBlendModel(BaseSettingsModel): enabled: bool = Field(True) optional: bool = Field(title="Optional") @@ -53,6 +63,16 @@ class PublishPuginsModel(BaseSettingsModel): title="Validate Camera Zero Keyframe", section="Validators" ) + ValidateFileSaved: ValidateFileSavedModel = Field( + default_factory=ValidateFileSavedModel, + title="Validate File Saved", + section="Validators" + ) + ValidateRenderCameraIsSet: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Camera Is Set", + section="Validators" + ) ValidateMeshHasUvs: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Mesh Has Uvs" @@ -118,6 +138,17 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateFileSaved": { + "enabled": True, + "optional": False, + "active": True, + "exclude_families": [] + }, + "ValidateRenderCameraIsSet": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateMeshHasUvs": { "enabled": True, "optional": True, diff --git a/server_addon/blender/server/settings/render_settings.py b/server_addon/blender/server/settings/render_settings.py new file mode 100644 index 0000000000..bef16328d6 --- /dev/null +++ b/server_addon/blender/server/settings/render_settings.py @@ -0,0 +1,106 @@ +"""Providing models and values for Blender Render Settings.""" +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + return [ + {"value": "exr", "label": "OpenEXR"}, + {"value": "bmp", "label": "BMP"}, + {"value": "rgb", "label": "Iris"}, + {"value": "png", "label": "PNG"}, + {"value": "jpg", "label": "JPEG"}, + {"value": "jp2", "label": "JPEG 2000"}, + {"value": "tga", "label": "Targa"}, + {"value": "tif", "label": "TIFF"}, + ] + + +def aov_list_enum(): + return [ + {"value": "empty", "label": "< none >"}, + {"value": "combined", "label": "Combined"}, + {"value": "z", "label": "Z"}, + {"value": "mist", "label": "Mist"}, + {"value": "normal", "label": "Normal"}, + {"value": "diffuse_light", "label": "Diffuse Light"}, + {"value": "diffuse_color", "label": "Diffuse Color"}, + {"value": "specular_light", "label": "Specular Light"}, + {"value": "specular_color", "label": "Specular Color"}, + {"value": "volume_light", "label": "Volume Light"}, + {"value": "emission", "label": "Emission"}, + {"value": "environment", "label": "Environment"}, + {"value": "shadow", "label": "Shadow"}, + {"value": "ao", "label": "Ambient Occlusion"} + ] + + +def custom_passes_types_enum(): + return [ + {"value": "COLOR", "label": "Color"}, + {"value": "VALUE", "label": "Value"}, + ] + + +class CustomPassesModel(BaseSettingsModel): + """Custom Passes""" + _layout = "compact" + + attribute: str = Field("", title="Attribute name") + value: str = Field( + "Color", + title="Type", + enum_resolver=custom_passes_types_enum + ) + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default Render Image Folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator Character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + "exr", + title="Image Format", + enum_resolver=image_format_enum + ) + multilayer_exr: bool = Field( + title="Multilayer (EXR)" + ) + aov_list: list[str] = Field( + default_factory=list, + enum_resolver=aov_list_enum, + title="AOVs to create" + ) + custom_passes: list[CustomPassesModel] = Field( + default_factory=list, + title="Custom Passes", + description=( + "Add custom AOVs. They are added to the view layer and in the " + "Compositing Nodetree,\nbut they need to be added manually to " + "the Shader Nodetree." + ) + ) + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", + "image_format": "exr", + "multilayer_exr": True, + "aov_list": [], + "custom_passes": [] +} diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d1b667345..a29caa7ba1 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -208,6 +208,16 @@ class CelactionSubmitDeadlineModel(BaseSettingsModel): ) +class BlenderSubmitDeadlineModel(BaseSettingsModel): + enabled: bool = Field(True) + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + use_published: bool = Field(title="Use Published scene") + priority: int = Field(title="Priority") + chunk_size: int = Field(title="Frame per Task") + group: str = Field("", title="Group Name") + + class AOVFilterSubmodel(BaseSettingsModel): _layout = "expanded" name: str = Field(title="Host") @@ -276,8 +286,10 @@ class PublishPluginsModel(BaseSettingsModel): title="After Effects to deadline") CelactionSubmitDeadline: CelactionSubmitDeadlineModel = Field( default_factory=CelactionSubmitDeadlineModel, - title="Celaction Submit Deadline" - ) + title="Celaction Submit Deadline") + BlenderSubmitDeadline: BlenderSubmitDeadlineModel = Field( + default_factory=BlenderSubmitDeadlineModel, + title="Blender Submit Deadline") ProcessSubmittedJobOnFarm: ProcessSubmittedJobOnFarmModel = Field( default_factory=ProcessSubmittedJobOnFarmModel, title="Process submitted job on farm.") @@ -384,6 +396,15 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": True, + "optional": False, + "active": True, + "use_published": True, + "priority": 50, + "chunk_size": 10, + "group": "none" + }, "ProcessSubmittedJobOnFarm": { "enabled": True, "deadline_department": "", From 1ee944d03c07172d5dd6919eab7b4a974ab52369 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 24 Aug 2023 15:33:53 +0100 Subject: [PATCH 026/327] Increase workfile version after render publish --- .../blender/plugins/publish/increment_workfile_version.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 27fa4baf28..5f49ad7185 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -9,7 +9,8 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action", "layout", "blendScene"] + families = ["animation", "model", "rig", "action", "layout", "blendScene", + "renderlayer"] def process(self, context): From 481e814858e9c90e4b8308707d341387cb151fd9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 24 Aug 2023 16:39:50 +0100 Subject: [PATCH 027/327] Save the workfile after creating the render instance Blender, by design, doesn't set the file as dirty if modifications happen by script. So, when creating the instance and setting the render settings, the file is not marked as dirty. This means that there is the risk of sending to deadline a file without the right settings. Even the validator to check that the file is saved will detect the file as saved, even if it isn't. The only solution for now it is to force the file to be saved. --- .../hosts/blender/plugins/create/create_render.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 2952baafd3..62700cb55c 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -288,4 +288,15 @@ class CreateRenderlayer(plugin.Creator): bpy.data.collections.remove(asset_group) raise + # TODO: this is undesiderable, but it's the only way to be sure that + # the file is saved before the render starts. + # Blender, by design, doesn't set the file as dirty if modifications + # happen by script. So, when creating the instance and setting the + # render settings, the file is not marked as dirty. This means that + # there is the risk of sending to deadline a file without the right + # settings. Even the validator to check that the file is saved will + # detect the file as saved, even if it isn't. The only solution for + # now it is to force the file to be saved. + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + return asset_group From d4ee32a9b78e06ecb25422a44487893bb8d40510 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 12:51:47 +0800 Subject: [PATCH 028/327] pre-hook ocio configuration for max 2024 --- openpype/hooks/pre_ocio_hook.py | 2 +- openpype/hosts/max/api/lib.py | 33 ++++++++++++++++++++++++++++++++- openpype/hosts/max/api/menu.py | 8 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 1307ed9f76..4eee48d57c 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -13,7 +13,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "max", + "max", "3dsmax", "houdini", "maya", "nuke", diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index ccd4cd67e1..d32aa8599a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" +import os import contextlib import json from typing import Any, Dict, Union import six +from openpype.pipeline import get_current_project_name +from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( - get_current_project, get_current_project_asset,) + get_current_project, get_current_project_asset) from pymxs import runtime as rt JSON_PREFIX = "JSON::" @@ -277,6 +280,7 @@ def set_context_setting(): """ reset_scene_resolution() reset_frame_range() + reset_colorspace() def get_max_version(): @@ -312,3 +316,30 @@ def set_timeline(frameStart, frameEnd): """ rt.animationRange = rt.interval(frameStart, frameEnd) return rt.animationRange + + +def reset_colorspace(): + """OCIO Configuration + Supports in 3dsMax 2024+ + + """ + if int(get_max_version) < 2024: + return + project_name = get_current_project_name() + ocio_config_path = os.environ.get("OCIO") + global_imageio = get_project_settings( + project_name)["global"]["imageio"] + if global_imageio["activate_global_color_management"]: + ocio_config = global_imageio["ocio_config"] + ocio_config_path = ocio_config["filepath"][-1] + + max_imageio = get_project_settings( + project_name)["global"]["imageio"] + if max_imageio["activate_global_color_management"]: + ocio_config = max_imageio["ocio_config"] + if ocio_config["override_global_config"]: + ocio_config_path = ocio_config["filepath"][0] + + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") + colorspace_mgr.OCIOConfigPath = ocio_config_path diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 066cc90039..aee4568669 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -119,6 +119,10 @@ class OpenPypeMenu(object): frame_action.triggered.connect(self.frame_range_callback) openpype_menu.addAction(frame_action) + colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) + colorspace_action.triggered.connect(self.colospace_setting_callback) + openpype_menu.addAction(colorspace_action) + return openpype_menu def load_callback(self): @@ -148,3 +152,7 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() + + def colospace_setting_callback(self): + """Callback to reset OCIO colorspace setting""" + return lib.reset_colorspace() \ No newline at end of file From 20df677e48d52b00834348e813017f99d9c2a07d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 29 Aug 2023 22:39:49 +0800 Subject: [PATCH 029/327] add ocio display view transform options in creator settings --- openpype/hosts/max/api/lib.py | 10 +++---- .../hosts/max/plugins/create/create_render.py | 28 ++++++++++++++++++- .../max/plugins/publish/collect_render.py | 17 +++++++++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index d32aa8599a..6218fd8351 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -323,9 +323,11 @@ def reset_colorspace(): Supports in 3dsMax 2024+ """ - if int(get_max_version) < 2024: + if int(get_max_version()) < 2024: return project_name = get_current_project_name() + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") ocio_config_path = os.environ.get("OCIO") global_imageio = get_project_settings( project_name)["global"]["imageio"] @@ -334,12 +336,10 @@ def reset_colorspace(): ocio_config_path = ocio_config["filepath"][-1] max_imageio = get_project_settings( - project_name)["global"]["imageio"] - if max_imageio["activate_global_color_management"]: + project_name)["max"]["imageio"] + if max_imageio["activate_host_color_management"]: ocio_config = max_imageio["ocio_config"] if ocio_config["override_global_config"]: ocio_config_path = ocio_config["filepath"][0] - colorspace_mgr = rt.ColorPipelineMgr - colorspace_mgr.Mode = rt.Name("OCIO_Custom") colorspace_mgr.OCIOConfigPath = ocio_config_path diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 235046684e..f3a4c7d7fa 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -2,7 +2,10 @@ """Creator plugin for creating camera.""" import os from openpype.hosts.max.api import plugin +from openpype.hosts.max.api.lib import get_max_version from openpype.hosts.max.api.lib_rendersettings import RenderSettings +from openpype.lib import EnumDef +from pymxs import runtime as rt class CreateRender(plugin.MaxCreator): @@ -13,11 +16,13 @@ class CreateRender(plugin.MaxCreator): icon = "gear" def create(self, subset_name, instance_data, pre_create_data): - from pymxs import runtime as rt sel_obj = list(rt.selection) file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename + instance_data["ocio_display_view_transform"] = ( + pre_create_data.get("ocio_display_view_transform") + ) instance = super(CreateRender, self).create( subset_name, @@ -30,3 +35,24 @@ class CreateRender(plugin.MaxCreator): RenderSettings(self.project_settings).set_render_camera(sel_obj) # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) + ocio_display = instance.data.get("ocio_display") + if ocio_display: + self.ocio_display = ocio_display + + def get_pre_create_attr_defs(self): + attrs = super(CreateRender, self).get_pre_create_attr_defs() + ocio_display_view_transform_list = [] + colorspace_mgr = rt.ColorPipelineMgr + displays = colorspace_mgr.GetDisplayList() + for display in sorted(displays): + views = colorspace_mgr.GetViewList(display) + for view in sorted(views): + ocio_display_view_transform_list.append({ + "value": "||".join((display, view)) + }) + return attrs + [ + EnumDef("ocio_display_view_transform", + ocio_display_view_transform_list, + default="", + label="OCIO Displays and Views") + ] diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index db5c84fad9..d003cbac06 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,6 +4,7 @@ import os import pyblish.api from pymxs import runtime as rt +from openpype.lib import EnumDef from openpype.pipeline import get_current_asset_name from openpype.hosts.max.api import colorspace from openpype.hosts.max.api.lib import get_max_version, get_current_renderer @@ -58,9 +59,19 @@ class CollectRender(pyblish.api.InstancePlugin): # most of the 3dsmax renderers # so this is currently hard coded # TODO: add options for redshift/vray ocio config - instance.data["colorspaceConfig"] = "" - instance.data["colorspaceDisplay"] = "sRGB" - instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + if int(get_max_version()) >= 2024: + display_view_transform = instance.data["ocio_display_view_transform"] + display, view_transform = display_view_transform.split("||") + colorspace_mgr = rt.ColorPipelineMgr + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + + else: + instance.data["colorspaceConfig"] = "" + instance.data["colorspaceDisplay"] = "sRGB" + instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] From 1c3764ff5958968b9ef5953521b18c3dcbff5ef2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 29 Aug 2023 22:42:34 +0800 Subject: [PATCH 030/327] hound --- openpype/hosts/max/api/menu.py | 2 +- openpype/hosts/max/plugins/create/create_render.py | 1 - openpype/hosts/max/plugins/publish/collect_render.py | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index aee4568669..670270e821 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -155,4 +155,4 @@ class OpenPypeMenu(object): def colospace_setting_callback(self): """Callback to reset OCIO colorspace setting""" - return lib.reset_colorspace() \ No newline at end of file + return lib.reset_colorspace() diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index f3a4c7d7fa..39f95c3b03 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -2,7 +2,6 @@ """Creator plugin for creating camera.""" import os from openpype.hosts.max.api import plugin -from openpype.hosts.max.api.lib import get_max_version from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype.lib import EnumDef from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index d003cbac06..12a0ac5487 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,7 +4,6 @@ import os import pyblish.api from pymxs import runtime as rt -from openpype.lib import EnumDef from openpype.pipeline import get_current_asset_name from openpype.hosts.max.api import colorspace from openpype.hosts.max.api.lib import get_max_version, get_current_renderer @@ -60,7 +59,7 @@ class CollectRender(pyblish.api.InstancePlugin): # so this is currently hard coded # TODO: add options for redshift/vray ocio config if int(get_max_version()) >= 2024: - display_view_transform = instance.data["ocio_display_view_transform"] + display_view_transform = instance.data["ocio_display_view_transform"] # noqa display, view_transform = display_view_transform.split("||") colorspace_mgr = rt.ColorPipelineMgr instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath From 42033a02946ee2e3480365613eef39ad1b7f3881 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 30 Aug 2023 21:09:12 +0800 Subject: [PATCH 031/327] remove unnecessary codes --- openpype/hosts/max/plugins/create/create_render.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 39f95c3b03..4f97802325 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -35,8 +35,6 @@ class CreateRender(plugin.MaxCreator): # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) ocio_display = instance.data.get("ocio_display") - if ocio_display: - self.ocio_display = ocio_display def get_pre_create_attr_defs(self): attrs = super(CreateRender, self).get_pre_create_attr_defs() From f0436f78c48f887b70b9438d626f4350634517a4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 30 Aug 2023 21:10:29 +0800 Subject: [PATCH 032/327] hound --- openpype/hosts/max/plugins/create/create_render.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 4f97802325..7575d297e3 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -34,7 +34,6 @@ class CreateRender(plugin.MaxCreator): RenderSettings(self.project_settings).set_render_camera(sel_obj) # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) - ocio_display = instance.data.get("ocio_display") def get_pre_create_attr_defs(self): attrs = super(CreateRender, self).get_pre_create_attr_defs() From fc3598ca2b3e249ae4dc52b606ce33cc2bc2dbab Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 23:27:03 +0800 Subject: [PATCH 033/327] oscar's comment on the code changes --- openpype/hosts/max/api/lib.py | 3 +++ openpype/hosts/max/api/menu.py | 6 +----- openpype/hosts/max/plugins/publish/collect_render.py | 9 ++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 6218fd8351..b5fba73c72 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -341,5 +341,8 @@ def reset_colorspace(): ocio_config = max_imageio["ocio_config"] if ocio_config["override_global_config"]: ocio_config_path = ocio_config["filepath"][0] + if not ocio_config_path: + # use the default ocio config path instead + ocio_config_path = ocio_config["filepath"][-1] colorspace_mgr.OCIOConfigPath = ocio_config_path diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 670270e821..e21ca32712 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -120,7 +120,7 @@ class OpenPypeMenu(object): openpype_menu.addAction(frame_action) colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) - colorspace_action.triggered.connect(self.colospace_setting_callback) + colorspace_action.triggered.connect(lib.reset_colorspace()) openpype_menu.addAction(colorspace_action) return openpype_menu @@ -152,7 +152,3 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() - - def colospace_setting_callback(self): - """Callback to reset OCIO colorspace setting""" - return lib.reset_colorspace() diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 12a0ac5487..0a745f1772 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -58,6 +58,10 @@ class CollectRender(pyblish.api.InstancePlugin): # most of the 3dsmax renderers # so this is currently hard coded # TODO: add options for redshift/vray ocio config + instance.data["colorspaceConfig"] = "" + instance.data["colorspaceDisplay"] = "sRGB" + instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + if int(get_max_version()) >= 2024: display_view_transform = instance.data["ocio_display_view_transform"] # noqa display, view_transform = display_view_transform.split("||") @@ -66,11 +70,6 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform - else: - instance.data["colorspaceConfig"] = "" - instance.data["colorspaceDisplay"] = "sRGB" - instance.data["colorspaceView"] = "ACES 1.0 SDR-video" - instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] From 8ea97559201e1ea9eb77e9b9109296f903429a53 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 1 Sep 2023 20:28:50 +0800 Subject: [PATCH 034/327] Libor's comment on the ocio settngs in publish tab and wip of the small popup widget for setting ocio config --- openpype/hosts/max/api/lib.py | 42 +++++++++++++++++++ openpype/hosts/max/api/pipeline.py | 3 ++ .../hosts/max/plugins/create/create_render.py | 37 ++++++++-------- .../max/plugins/publish/collect_render.py | 3 +- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index b5fba73c72..7c8a5c86d4 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -15,6 +15,33 @@ from pymxs import runtime as rt JSON_PREFIX = "JSON::" +class Context: + main_window = None + context_label = None + project_name = os.getenv("AVALON_PROJECT") + # Workfile related code + workfiles_launched = False + workfiles_tool_timer = None + + + +def get_main_window(): + """Acquire Max's main window""" + from qtpy import QtWidgets + if Context.main_window is None: + + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "QmaxApplicationWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + Context.main_window = widget + break + return Context.main_window + + def imprint(node_name: str, data: dict) -> bool: node = rt.GetNodeByName(node_name) if not node: @@ -346,3 +373,18 @@ def reset_colorspace(): ocio_config_path = ocio_config["filepath"][-1] colorspace_mgr.OCIOConfigPath = ocio_config_path + + +def check_colorspace(): + parent = get_main_window() + if int(get_max_version()) >= 2024: + color_mgr = rt.ColorPipelineMgr + if color_mgr.Mode != rt.Name("OCIO_Custom"): + from openpype.widgets import popup + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Warning: Wrong OCIO Mode") + dialog.setMessage("This scene has wrong OCIO " + "Mode setting.") + dialog.widgets["button"].setText("Fix") + dialog.on_clicked.connect(reset_colorspace) + dialog.show() diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 03b85a4066..f7d23236ea 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -56,6 +56,9 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): rt.callbacks.addScript(rt.Name('systemPostNew'), context_setting) + rt.callbacks.addScript(rt.Name('filePostOpen'), + lib.check_colorspace) + def has_unsaved_changes(self): # TODO: how to get it from 3dsmax? return True diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 7575d297e3..41b38eeca3 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -3,6 +3,7 @@ import os from openpype.hosts.max.api import plugin from openpype.hosts.max.api.lib_rendersettings import RenderSettings +from openpype.hosts.max.api.lib import get_max_version from openpype.lib import EnumDef from pymxs import runtime as rt @@ -18,11 +19,6 @@ class CreateRender(plugin.MaxCreator): sel_obj = list(rt.selection) file = rt.maxFileName filename, _ = os.path.splitext(file) - instance_data["AssetName"] = filename - instance_data["ocio_display_view_transform"] = ( - pre_create_data.get("ocio_display_view_transform") - ) - instance = super(CreateRender, self).create( subset_name, instance_data, @@ -35,20 +31,27 @@ class CreateRender(plugin.MaxCreator): # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) - def get_pre_create_attr_defs(self): - attrs = super(CreateRender, self).get_pre_create_attr_defs() - ocio_display_view_transform_list = [] - colorspace_mgr = rt.ColorPipelineMgr - displays = colorspace_mgr.GetDisplayList() - for display in sorted(displays): - views = colorspace_mgr.GetViewList(display) - for view in sorted(views): - ocio_display_view_transform_list.append({ - "value": "||".join((display, view)) + def get_instance_attr_defs(self): + ocio_display_view_transform_list = ["sRGB||ACES 1.0 SDR-video"] + if int(get_max_version()) >= 2024: + display_view_default = "" + ocio_display_view_transform_list = [] + colorspace_mgr = rt.ColorPipelineMgr + displays = colorspace_mgr.GetDisplayList() + for display in sorted(displays): + views = colorspace_mgr.GetViewList(display) + for view in sorted(views): + ocio_display_view_transform_list.append({ + "value": "||".join((display, view)) }) - return attrs + [ + if display == "ACES" and view == "sRGB": + display_view_default = "{0}||{1}".format( + display, view + ) + + return [ EnumDef("ocio_display_view_transform", ocio_display_view_transform_list, - default="", + default=display_view_default, label="OCIO Displays and Views") ] diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 1b8ca30a32..522d20322e 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -66,7 +66,8 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceView"] = "ACES 1.0 SDR-video" if int(get_max_version()) >= 2024: - display_view_transform = instance.data["ocio_display_view_transform"] # noqa + creator_attribute = instance.data["creator_attributes"] + display_view_transform = creator_attribute["ocio_display_view_transform"] # noqa display, view_transform = display_view_transform.split("||") colorspace_mgr = rt.ColorPipelineMgr instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath From 3caaf2a5d5800e9b640d7bc678fae54825a71cc5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 1 Sep 2023 21:31:10 +0800 Subject: [PATCH 035/327] hound & fixing the render camera issue --- openpype/hosts/max/api/lib.py | 1 - openpype/hosts/max/plugins/create/create_render.py | 2 +- openpype/hosts/max/plugins/publish/collect_render.py | 2 ++ .../modules/deadline/plugins/publish/submit_max_deadline.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 7c8a5c86d4..7afe14ddcb 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -24,7 +24,6 @@ class Context: workfiles_tool_timer = None - def get_main_window(): """Acquire Max's main window""" from qtpy import QtWidgets diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 41b38eeca3..c12ae8155a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -43,7 +43,7 @@ class CreateRender(plugin.MaxCreator): for view in sorted(views): ocio_display_view_transform_list.append({ "value": "||".join((display, view)) - }) + }) if display == "ACES" and view == "sRGB": display_view_default = "{0}||{1}".format( display, view diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 522d20322e..675e3b6a57 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -35,6 +35,8 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() + if instance.data.get("members"): + camera = instance.data["members"][-1] instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 63c6e4a0c7..a9f440668c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -238,7 +238,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") if instance.data["cameras"]: - plugin_info["Camera0"] = None + plugin_info["Camera0"] = instance.data["cameras"][0] plugin_info["Camera"] = instance.data["cameras"][0] plugin_info["Camera1"] = instance.data["cameras"][0] self.log.debug("plugin data:{}".format(plugin_data)) From 0cee44306e50e8f012ea559e477ae71770deb5f9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 23:02:02 +0800 Subject: [PATCH 036/327] add colorspace data in collect review --- .../max/plugins/publish/collect_review.py | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 7aeb45f46b..e3ad59ea7d 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -3,7 +3,8 @@ import pyblish.api from pymxs import runtime as rt -from openpype.lib import BoolDef +from openpype.lib import BoolDef, EnumDef +from openpype.hosts.max.api.lib import get_max_version from openpype.pipeline.publish import OpenPypePyblishPluginMixin @@ -43,6 +44,16 @@ class CollectReview(pyblish.api.InstancePlugin, "dspSafeFrame": attr_values.get("dspSafeFrame"), "dspFrameNums": attr_values.get("dspFrameNums") } + + if int(get_max_version()) >= 2024: + display_view_transform = attr_values.get( + "ocio_display_view_transform") + display, view_transform = display_view_transform.split("||") + colorspace_mgr = rt.ColorPipelineMgr + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -54,8 +65,28 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - + ocio_display_view_transform_list = ["sRGB||ACES 1.0 SDR-video"] + display_view_default = "" + if int(get_max_version()) >= 2024: + display_view_default = "" + ocio_display_view_transform_list = [] + colorspace_mgr = rt.ColorPipelineMgr + displays = colorspace_mgr.GetDisplayList() + for display in sorted(displays): + views = colorspace_mgr.GetViewList(display) + for view in sorted(views): + ocio_display_view_transform_list.append({ + "value": "||".join((display, view)) + }) + if display == "ACES" and view == "sRGB": + display_view_default = "{0}||{1}".format( + display, view + ) return [ + EnumDef("ocio_display_view_transform", + ocio_display_view_transform_list, + default=display_view_default, + label="OCIO Displays and Views"), BoolDef("dspGeometry", label="Geometry", default=True), From c5013305bd1585c057807bd86629f4bdff571a62 Mon Sep 17 00:00:00 2001 From: Kayla Man <64118225+moonyuet@users.noreply.github.com> Date: Tue, 5 Sep 2023 21:15:32 +0800 Subject: [PATCH 037/327] Update openpype/hosts/max/api/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/max/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 23116b0365..b909c1c156 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -388,7 +388,7 @@ def check_colorspace(): dialog.setWindowTitle("Warning: Wrong OCIO Mode") dialog.setMessage("This scene has wrong OCIO " "Mode setting.") - dialog.widgets["button"].setText("Fix") + dialog.setButtonText("Fix") dialog.on_clicked.connect(reset_colorspace) dialog.show() From c88b7106fd896d7de2c111e5ffc1a8dbf86191c6 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 5 Sep 2023 21:30:59 +0800 Subject: [PATCH 038/327] resolve the qt style issue with jakub's comment --- openpype/hosts/max/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index b909c1c156..a2d45fee77 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -11,8 +11,10 @@ from openpype.pipeline import get_current_project_name from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( get_current_project, get_current_project_asset) +from openpype.style import load_stylesheet from pymxs import runtime as rt + JSON_PREFIX = "JSON::" log = logging.getLogger("openpype.hosts.max") @@ -389,10 +391,10 @@ def check_colorspace(): dialog.setMessage("This scene has wrong OCIO " "Mode setting.") dialog.setButtonText("Fix") + dialog.setStyleSheet(load_stylesheet()) dialog.on_clicked.connect(reset_colorspace) dialog.show() - def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): """Return unique namespace From 4aa8f5ac5f333e76894b6fbc3a82e9a43602baed Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 16:35:46 +0800 Subject: [PATCH 039/327] big roy's comments on the code fix --- openpype/hooks/pre_ocio_hook.py | 2 +- openpype/hosts/max/api/lib.py | 67 ++++++++----------- openpype/hosts/max/api/menu.py | 6 +- .../hosts/max/plugins/create/create_render.py | 24 +++---- .../max/plugins/publish/collect_render.py | 8 ++- .../max/plugins/publish/collect_review.py | 18 ++--- .../plugins/publish/submit_max_deadline.py | 7 +- 7 files changed, 63 insertions(+), 69 deletions(-) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index e0877e19bc..e695cf3fe8 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -13,7 +13,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "max", "3dsmax", + "3dsmax", "houdini", "maya", "nuke", diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index a2d45fee77..e5d333c275 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -7,7 +7,7 @@ import json from typing import Any, Dict, Union import six -from openpype.pipeline import get_current_project_name +from openpype.pipeline import get_current_project_name, colorspace from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( get_current_project, get_current_project_asset) @@ -19,29 +19,18 @@ JSON_PREFIX = "JSON::" log = logging.getLogger("openpype.hosts.max") -class Context: - main_window = None - context_label = None - project_name = os.getenv("AVALON_PROJECT") - # Workfile related code - workfiles_launched = False - workfiles_tool_timer = None - - def get_main_window(): """Acquire Max's main window""" from qtpy import QtWidgets - if Context.main_window is None: - top_widgets = QtWidgets.QApplication.topLevelWidgets() - name = "QmaxApplicationWindow" - for widget in top_widgets: - if ( - widget.inherits("QMainWindow") - and widget.metaObject().className() == name - ): - Context.main_window = widget - break - return Context.main_window + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "QmaxApplicationWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + return widget + raise RuntimeError('Count not find 3dsMax main window.') def imprint(node_name: str, data: dict) -> bool: @@ -356,23 +345,15 @@ def reset_colorspace(): return project_name = get_current_project_name() colorspace_mgr = rt.ColorPipelineMgr - colorspace_mgr.Mode = rt.Name("OCIO_Custom") - ocio_config_path = os.environ.get("OCIO") - global_imageio = get_project_settings( - project_name)["global"]["imageio"] - if global_imageio["activate_global_color_management"]: - ocio_config = global_imageio["ocio_config"] - ocio_config_path = ocio_config["filepath"][-1] + project_settings = get_project_settings(project_name) - max_imageio = get_project_settings( - project_name)["max"]["imageio"] - if max_imageio["activate_host_color_management"]: - ocio_config = max_imageio["ocio_config"] - if ocio_config["override_global_config"]: - ocio_config_path = ocio_config["filepath"][0] - if not ocio_config_path: - # use the default ocio config path instead - ocio_config_path = ocio_config["filepath"][-1] + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data: + ocio_config_path = max_config_data["path"] + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") + colorspace_mgr.OCIOConfigPath = ocio_config_path colorspace_mgr.OCIOConfigPath = ocio_config_path @@ -384,12 +365,20 @@ def check_colorspace(): "because Max main window can't be found.") if int(get_max_version()) >= 2024: color_mgr = rt.ColorPipelineMgr - if color_mgr.Mode != rt.Name("OCIO_Custom"): + project_name = get_current_project_name() + project_settings = get_project_settings( + project_name) + global_imageio = project_settings["global"]["imageio"] + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + config_enabled = global_imageio["activate_global_color_management"] or ( + max_config_data) + if config_enabled and color_mgr.Mode != rt.Name("OCIO_Custom"): from openpype.widgets import popup dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Warning: Wrong OCIO Mode") dialog.setMessage("This scene has wrong OCIO " - "Mode setting.") + "Mode setting.") dialog.setButtonText("Fix") dialog.setStyleSheet(load_stylesheet()) dialog.on_clicked.connect(reset_colorspace) diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index e21ca32712..364f9cd5c5 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -120,7 +120,7 @@ class OpenPypeMenu(object): openpype_menu.addAction(frame_action) colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) - colorspace_action.triggered.connect(lib.reset_colorspace()) + colorspace_action.triggered.connect(self.colorspace_callback) openpype_menu.addAction(colorspace_action) return openpype_menu @@ -152,3 +152,7 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() + + def colorspace_callback(self): + """Callback to reset colorspace""" + return lib.reset_colorspace() diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index c12ae8155a..6c60f432d3 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" -import os from openpype.hosts.max.api import plugin from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype.hosts.max.api.lib import get_max_version @@ -17,8 +16,6 @@ class CreateRender(plugin.MaxCreator): def create(self, subset_name, instance_data, pre_create_data): sel_obj = list(rt.selection) - file = rt.maxFileName - filename, _ = os.path.splitext(file) instance = super(CreateRender, self).create( subset_name, instance_data, @@ -32,26 +29,25 @@ class CreateRender(plugin.MaxCreator): RenderSettings().render_output(container_name) def get_instance_attr_defs(self): - ocio_display_view_transform_list = ["sRGB||ACES 1.0 SDR-video"] if int(get_max_version()) >= 2024: - display_view_default = "" - ocio_display_view_transform_list = [] + default_value = "" + display_views = [] colorspace_mgr = rt.ColorPipelineMgr - displays = colorspace_mgr.GetDisplayList() - for display in sorted(displays): - views = colorspace_mgr.GetViewList(display) - for view in sorted(views): - ocio_display_view_transform_list.append({ + for display in sorted(colorspace_mgr.GetDisplayList()): + for view in sorted(colorspace_mgr.GetViewList(display)): + display_views.append({ "value": "||".join((display, view)) }) if display == "ACES" and view == "sRGB": - display_view_default = "{0}||{1}".format( + default_value = "{0}||{1}".format( display, view ) + else: + display_views = ["sRGB||ACES 1.0 SDR-video"] return [ EnumDef("ocio_display_view_transform", - ocio_display_view_transform_list, - default=display_view_default, + display_views, + default=default_value, label="OCIO Displays and Views") ] diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 675e3b6a57..729a5b173c 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -36,7 +36,11 @@ class CollectRender(pyblish.api.InstancePlugin): camera = rt.viewport.GetCamera() if instance.data.get("members"): - camera = instance.data["members"][-1] + camera_list = [member for member in instance.data["members"] + if rt.ClassOf(member) == rt.Camera.Classes] + if camera_list: + camera = camera_list[-1] + instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: @@ -70,7 +74,7 @@ class CollectRender(pyblish.api.InstancePlugin): if int(get_max_version()) >= 2024: creator_attribute = instance.data["creator_attributes"] display_view_transform = creator_attribute["ocio_display_view_transform"] # noqa - display, view_transform = display_view_transform.split("||") + display, view_transform = display_view_transform.split("||", 1) colorspace_mgr = rt.ColorPipelineMgr instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index e3ad59ea7d..686dc2ed2c 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -48,7 +48,7 @@ class CollectReview(pyblish.api.InstancePlugin, if int(get_max_version()) >= 2024: display_view_transform = attr_values.get( "ocio_display_view_transform") - display, view_transform = display_view_transform.split("||") + display, view_transform = display_view_transform.split("||", 1) colorspace_mgr = rt.ColorPipelineMgr instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display @@ -65,27 +65,27 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - ocio_display_view_transform_list = ["sRGB||ACES 1.0 SDR-video"] - display_view_default = "" + default_value = "" + display_views = [] if int(get_max_version()) >= 2024: - display_view_default = "" - ocio_display_view_transform_list = [] colorspace_mgr = rt.ColorPipelineMgr displays = colorspace_mgr.GetDisplayList() for display in sorted(displays): views = colorspace_mgr.GetViewList(display) for view in sorted(views): - ocio_display_view_transform_list.append({ + display_views.append({ "value": "||".join((display, view)) }) if display == "ACES" and view == "sRGB": - display_view_default = "{0}||{1}".format( + default_value = "{0}||{1}".format( display, view ) + else: + display_views = ["sRGB||ACES 1.0 SDR-video"] return [ EnumDef("ocio_display_view_transform", - ocio_display_view_transform_list, - default=display_view_default, + items=display_views, + default=default_value, label="OCIO Displays and Views"), BoolDef("dspGeometry", label="Geometry", diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index a9f440668c..073da3019a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -238,9 +238,10 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") if instance.data["cameras"]: - plugin_info["Camera0"] = instance.data["cameras"][0] - plugin_info["Camera"] = instance.data["cameras"][0] - plugin_info["Camera1"] = instance.data["cameras"][0] + camera = instance.data["cameras"][0] + plugin_info["Camera0"] = camera + plugin_info["Camera"] = camera + plugin_info["Camera1"] = camera self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) From 1eaddb548553c896563a9e6557188b4f40a2ae89 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 16:47:07 +0800 Subject: [PATCH 040/327] hound --- openpype/hosts/max/api/lib.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index e5d333c275..0549309c0b 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" -import os import contextlib import logging import json @@ -366,12 +365,11 @@ def check_colorspace(): if int(get_max_version()) >= 2024: color_mgr = rt.ColorPipelineMgr project_name = get_current_project_name() - project_settings = get_project_settings( - project_name) + project_settings = get_project_settings(project_name) global_imageio = project_settings["global"]["imageio"] max_config_data = colorspace.get_imageio_config( - project_name, "max", project_settings) - config_enabled = global_imageio["activate_global_color_management"] or ( + project_name, "max", project_settings) + config_enabled = global_imageio["activate_global_color_management"] or ( # noqa max_config_data) if config_enabled and color_mgr.Mode != rt.Name("OCIO_Custom"): from openpype.widgets import popup From 35b4666043f7fb12368959687138002118dcb527 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 17:12:04 +0800 Subject: [PATCH 041/327] add functions to check if the 3dsmax is in batch mode before popup dialog exists --- openpype/hosts/max/api/lib.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 0549309c0b..0f86e0a07a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -313,6 +313,14 @@ def get_max_version(): return max_info[7] +def is_HEADLESS(): + """Check if 3dsMax runs in batch mode. + If it returns True, it runs in 3dsbatch.exe + If it returns False, it runs in 3dsmax.exe + """ + return rt.maxops.isInNonInteractiveMode() + + @contextlib.contextmanager def viewport_camera(camera): original = rt.viewport.getCamera() @@ -372,15 +380,16 @@ def check_colorspace(): config_enabled = global_imageio["activate_global_color_management"] or ( # noqa max_config_data) if config_enabled and color_mgr.Mode != rt.Name("OCIO_Custom"): - from openpype.widgets import popup - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Warning: Wrong OCIO Mode") - dialog.setMessage("This scene has wrong OCIO " - "Mode setting.") - dialog.setButtonText("Fix") - dialog.setStyleSheet(load_stylesheet()) - dialog.on_clicked.connect(reset_colorspace) - dialog.show() + if not is_HEADLESS: + from openpype.widgets import popup + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Warning: Wrong OCIO Mode") + dialog.setMessage("This scene has wrong OCIO " + "Mode setting.") + dialog.setButtonText("Fix") + dialog.setStyleSheet(load_stylesheet()) + dialog.on_clicked.connect(reset_colorspace) + dialog.show() def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): From fab390f8f036a2da48807a55fe7b207c177cdc0b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 21:51:47 +0800 Subject: [PATCH 042/327] big roy's comment on check_colorspace function and some fix on IS_HEADLESS() --- openpype/hosts/max/api/lib.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 0f86e0a07a..8cbb807607 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -374,18 +374,15 @@ def check_colorspace(): color_mgr = rt.ColorPipelineMgr project_name = get_current_project_name() project_settings = get_project_settings(project_name) - global_imageio = project_settings["global"]["imageio"] max_config_data = colorspace.get_imageio_config( project_name, "max", project_settings) - config_enabled = global_imageio["activate_global_color_management"] or ( # noqa - max_config_data) - if config_enabled and color_mgr.Mode != rt.Name("OCIO_Custom"): - if not is_HEADLESS: + if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): + if not is_HEADLESS(): from openpype.widgets import popup dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Warning: Wrong OCIO Mode") dialog.setMessage("This scene has wrong OCIO " - "Mode setting.") + "Mode setting.") dialog.setButtonText("Fix") dialog.setStyleSheet(load_stylesheet()) dialog.on_clicked.connect(reset_colorspace) From 6d1407f3462eb09b31d2dbb0daba9d37bddc0d1c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 6 Sep 2023 21:52:35 +0800 Subject: [PATCH 043/327] hound --- openpype/hosts/max/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8cbb807607..6eb328b505 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -382,7 +382,7 @@ def check_colorspace(): dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Warning: Wrong OCIO Mode") dialog.setMessage("This scene has wrong OCIO " - "Mode setting.") + "Mode setting.") dialog.setButtonText("Fix") dialog.setStyleSheet(load_stylesheet()) dialog.on_clicked.connect(reset_colorspace) From da7c47ab2fe6c9a87de6491de2cf71abd7fc98b2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 7 Sep 2023 14:47:22 +0800 Subject: [PATCH 044/327] add fbx extractors and new sets in rig family --- openpype/hosts/maya/api/fbx.py | 4 +- .../hosts/maya/plugins/create/create_rig.py | 22 ++++- .../plugins/publish/collect_rig_for_fbx.py | 45 +++++++++ .../maya/plugins/publish/extract_rig_fbx.py | 92 +++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_rig_fbx.py diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 260241f5fc..bd0e77e427 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -63,6 +63,7 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, + "referencedAssetsContent": bool, "triangulate": bool } @@ -104,7 +105,8 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False + "referencedAssetsContent": False, + "triangulate": False, } def __init__(self, log=None): diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 345ab6c00d..9b67c84980 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -1,6 +1,7 @@ from maya import cmds from openpype.hosts.maya.api import plugin +from openpype.lib import BoolDef class CreateRig(plugin.MayaCreator): @@ -12,6 +13,7 @@ class CreateRig(plugin.MayaCreator): icon = "wheelchair" def create(self, subset_name, instance_data, pre_create_data): + instance_data["fbx_enabled"] = pre_create_data.get("fbx_enabled") instance = super(CreateRig, self).create(subset_name, instance_data, @@ -20,6 +22,24 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") + # change name controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) + # change name pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) - cmds.sets([controls, pointcache], forceElement=instance_node) + if pre_create_data.get("fbx_enabled"): + skeleton = cmds.sets(name=subset_name + "_skeleton_SET", empty=True) + skeleton_mesh = cmds.sets(name=subset_name + "_skeletonMesh_SET", empty=True) + cmds.sets([controls, pointcache, + skeleton, skeleton_mesh], forceElement=instance_node) + else: + cmds.sets([controls, pointcache], forceElement=instance_node) + + def get_pre_create_attr_defs(self): + attrs = super(CreateRig, self).get_pre_create_attr_defs() + + return attrs + [ + BoolDef("fbx_enabled", + label="Fbx Export", + default=False), + + ] diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py new file mode 100644 index 0000000000..c57045a052 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectRigFbx(pyblish.api.InstancePlugin): + """Collect Unreal Skeletal Mesh.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect rig for fbx" + families = ["rig"] + + def process(self, instance): + if not instance.data.get("fbx_enabled"): + self.log.debug("Skipping collecting rig data for fbx..") + return + + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame + skeleton_sets = [ + i for i in instance[:] + if i.lower().endswith("skeleton_set") + ] + + skeleton_mesh_sets = [ + i for i in instance[:] + if i.lower().endswith("skeletonmesh_set") + ] + if skeleton_sets or skeleton_mesh_sets: + instance.data["families"] += ["fbx"] + instance.data["geometries"] = [] + instance.data["control_rigs"] = [] + instance.data["skeleton_mesh"] = [] + for skeleton_set in skeleton_sets: + skeleton_content = cmds.ls( + cmds.sets(skeleton_set, query=True), long=True) + if skeleton_content: + instance.data["control_rigs"] += skeleton_content + + for skeleton_mesh_set in skeleton_mesh_sets: + skeleton_mesh_content = cmds.ls( + cmds.sets(skeleton_mesh_set, query=True), long=True) + if skeleton_mesh_content: + instance.data["skeleton_mesh"] += skeleton_mesh_content diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py new file mode 100644 index 0000000000..da1a458c9e --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import maya.mel as mel # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import maintained_selection +from openpype.hosts.maya.api import fbx + + +class ExtractRigFBX(publish.Extractor): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Rig (FBX)" + families = ["rig"] + + def process(self, instance): + if not instance.data.get("fbx_enabled"): + self.log.debug("fbx extractor has been disable.." + "Skipping the action...") + return + + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace('\\', '/') + + self.log.debug("Extracting FBX to: {0}".format(path)) + + control_rigs = instance.data.get("control_rigs",[]) + skeletal_mesh = instance.data.get("skeleton_mesh", []) + members = control_rigs + skeletal_mesh + self._to_extract(instance, path, members) + + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) + + self.log.debug("Extract FBX successful to: {0}".format(path)) + if skeletal_mesh: + self._to_extract(instance, path, skeletal_mesh) + representation = { + 'name': 'fbxanim', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir, + "outputName": "fbxanim" + } + instance.data["representations"].append(representation) + self.log.debug("Extract animated FBX successful to: {0}".format(path)) + + def _to_extract(self, instance, path, members): + fbx_exporter = fbx.FBXExtractor(log=self.log) + control_rigs = instance.data.get("control_rigs",[]) + skeletal_mesh = instance.data.get("skeleton_mesh", []) + static_sets = control_rigs + skeletal_mesh + if members == static_sets: + instance.data["constraints"] = True + instance.data["referencedAssetsContent"] = True + if members == skeletal_mesh: + instance.data["constraints"] = True + instance.data["referencedAssetsContent"] = True + instance.data["animationOnly"] = True + + fbx_exporter.set_options_from_instance(instance) + + # Export + with maintained_selection(): + fbx_exporter.export(members, path) + cmds.select(members, r=1, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) From b54f263d4b7e977442b3087d0565da8a52770784 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 7 Sep 2023 14:55:52 +0800 Subject: [PATCH 045/327] hound --- openpype/hosts/maya/plugins/create/create_rig.py | 6 ++++-- .../hosts/maya/plugins/publish/collect_rig_for_fbx.py | 1 - openpype/hosts/maya/plugins/publish/extract_rig_fbx.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 9b67c84980..030aa23a22 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -27,8 +27,10 @@ class CreateRig(plugin.MayaCreator): # change name pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) if pre_create_data.get("fbx_enabled"): - skeleton = cmds.sets(name=subset_name + "_skeleton_SET", empty=True) - skeleton_mesh = cmds.sets(name=subset_name + "_skeletonMesh_SET", empty=True) + skeleton = cmds.sets( + name=subset_name + "_skeleton_SET", empty=True) + skeleton_mesh = cmds.sets( + name=subset_name + "_skeletonMesh_SET", empty=True) cmds.sets([controls, pointcache, skeleton, skeleton_mesh], forceElement=instance_node) else: diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index c57045a052..bef43aa5f4 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -28,7 +28,6 @@ class CollectRigFbx(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonmesh_set") ] if skeleton_sets or skeleton_mesh_sets: - instance.data["families"] += ["fbx"] instance.data["geometries"] = [] instance.data["control_rigs"] = [] instance.data["skeleton_mesh"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index da1a458c9e..687b686fb8 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -40,12 +40,11 @@ class ExtractRigFBX(publish.Extractor): self.log.debug("Extracting FBX to: {0}".format(path)) - control_rigs = instance.data.get("control_rigs",[]) + control_rigs = instance.data.get("control_rigs", []) skeletal_mesh = instance.data.get("skeleton_mesh", []) members = control_rigs + skeletal_mesh self._to_extract(instance, path, members) - if "representations" not in instance.data: instance.data["representations"] = [] @@ -68,11 +67,12 @@ class ExtractRigFBX(publish.Extractor): "outputName": "fbxanim" } instance.data["representations"].append(representation) - self.log.debug("Extract animated FBX successful to: {0}".format(path)) + self.log.debug( + "Extract animated FBX successful to: {0}".format(path)) def _to_extract(self, instance, path, members): fbx_exporter = fbx.FBXExtractor(log=self.log) - control_rigs = instance.data.get("control_rigs",[]) + control_rigs = instance.data.get("control_rigs", []) skeletal_mesh = instance.data.get("skeleton_mesh", []) static_sets = control_rigs + skeletal_mesh if members == static_sets: From d58b5a42f7f792d1a8198cf500fa5fb31752e4f2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 7 Sep 2023 20:46:39 +0800 Subject: [PATCH 046/327] implment fbx extractors in both animation and rig family --- openpype/hosts/maya/api/fbx.py | 12 ++-- .../hosts/maya/plugins/create/create_rig.py | 30 +++------ .../plugins/publish/collect_fbx_animation.py | 27 ++++++++ .../plugins/publish/collect_rig_for_fbx.py | 10 +-- .../plugins/publish/extract_fbx_animation.py | 60 +++++++++++++++++ .../maya/plugins/publish/extract_rig_fbx.py | 56 ++++------------ .../plugins/publish/validate_rig_contents.py | 66 ++++++++++++++++++- .../publish/validate_rig_controllers.py | 8 ++- ...idate_rig_controllers_arnold_attributes.py | 3 +- .../publish/validate_rig_out_set_node_ids.py | 19 +++++- .../publish/validate_rig_output_ids.py | 10 ++- 11 files changed, 213 insertions(+), 88 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_fbx_animation.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_fbx_animation.py diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index bd0e77e427..064ba00f08 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -6,6 +6,7 @@ from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa +from openpype.hosts.maya.api.lib import maintained_selection class FBXExtractor: @@ -63,8 +64,8 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "referencedAssetsContent": bool, - "triangulate": bool + "triangulate": bool, + "exportFileVersion": str } @property @@ -105,8 +106,8 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "referencedAssetsContent": False, "triangulate": False, + "exportFileVersion": "FBX201000" } def __init__(self, log=None): @@ -200,5 +201,6 @@ class FBXExtractor: path (str): Path to use for export. """ - cmds.select(members, r=True, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + with maintained_selection(): + cmds.select(members, r=True, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 030aa23a22..b4ff6fad07 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -13,7 +13,6 @@ class CreateRig(plugin.MayaCreator): icon = "wheelchair" def create(self, subset_name, instance_data, pre_create_data): - instance_data["fbx_enabled"] = pre_create_data.get("fbx_enabled") instance = super(CreateRig, self).create(subset_name, instance_data, @@ -22,26 +21,13 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") - # change name + # change name (_controls_set -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) - # change name + # change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) - if pre_create_data.get("fbx_enabled"): - skeleton = cmds.sets( - name=subset_name + "_skeleton_SET", empty=True) - skeleton_mesh = cmds.sets( - name=subset_name + "_skeletonMesh_SET", empty=True) - cmds.sets([controls, pointcache, - skeleton, skeleton_mesh], forceElement=instance_node) - else: - cmds.sets([controls, pointcache], forceElement=instance_node) - - def get_pre_create_attr_defs(self): - attrs = super(CreateRig, self).get_pre_create_attr_defs() - - return attrs + [ - BoolDef("fbx_enabled", - label="Fbx Export", - default=False), - - ] + skeleton = cmds.sets( + name=subset_name + "skeletonAnim_SET", empty=True) + skeleton_mesh = cmds.sets( + name=subset_name + "_skeletonMesh_SET", empty=True) + cmds.sets([controls, pointcache, + skeleton, skeleton_mesh], forceElement=instance_node) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py new file mode 100644 index 0000000000..e1b2fc0b7b --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectFbxAnimation(pyblish.api.InstancePlugin): + """Collect Unreal Skeletal Mesh.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Fbx Animation" + families = ["rig"] + + def process(self, instance): + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame + + skeleton_sets = [ + i for i in instance[:] + if i.lower().endswith("skeletonanim_set") + ] + if skeleton_sets: + for skeleton_set in skeleton_sets: + skeleton_content = cmds.ls( + cmds.sets(skeleton_set, query=True), long=True) + if skeleton_content: + instance.data["animated_skeleton"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index bef43aa5f4..6ade7451d6 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -11,16 +11,12 @@ class CollectRigFbx(pyblish.api.InstancePlugin): families = ["rig"] def process(self, instance): - if not instance.data.get("fbx_enabled"): - self.log.debug("Skipping collecting rig data for fbx..") - return - frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame skeleton_sets = [ i for i in instance[:] - if i.lower().endswith("skeleton_set") + if i.lower().endswith("skeletonanim_set") ] skeleton_mesh_sets = [ @@ -28,14 +24,12 @@ class CollectRigFbx(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonmesh_set") ] if skeleton_sets or skeleton_mesh_sets: - instance.data["geometries"] = [] - instance.data["control_rigs"] = [] instance.data["skeleton_mesh"] = [] for skeleton_set in skeleton_sets: skeleton_content = cmds.ls( cmds.sets(skeleton_set, query=True), long=True) if skeleton_content: - instance.data["control_rigs"] += skeleton_content + instance.data["animated_rigs"] += skeleton_content for skeleton_mesh_set in skeleton_mesh_sets: skeleton_mesh_content = cmds.ls( diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py new file mode 100644 index 0000000000..111a202f82 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import maya.mel as mel # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.pipeline.publish import OptionalPyblishPluginMixin +from openpype.hosts.maya.api import fbx + + +class ExtractRigFBX(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Animation (FBX)" + families = ["animation"] + + def process(self, instance): + if not self.is_active(instance.data): + return + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + # The export requires forward slashes because we need + # to format it into a string in a mel expression + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_set = instance.data.get("animated_skeleton", []) + + instance.data["constraints"] = True + instance.data["animationOnly"] = True + + fbx_exporter.set_options_from_instance(instance) + + # Export + fbx_exporter.export(out_set, path) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir, + "outputName": "fbxanim" + } + instance.data["representations"].append(representation) + + self.log.debug("Extract animated FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 687b686fb8..2aa02a21c3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -6,11 +6,12 @@ import maya.mel as mel # noqa import pyblish.api from openpype.pipeline import publish -from openpype.hosts.maya.api.lib import maintained_selection +from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.maya.api import fbx -class ExtractRigFBX(publish.Extractor): +class ExtractRigFBX(publish.Extractor, + OptionalPyblishPluginMixin): """Extract Rig in FBX format from Maya. This extracts the rig in fbx with the constraints @@ -24,11 +25,8 @@ class ExtractRigFBX(publish.Extractor): families = ["rig"] def process(self, instance): - if not instance.data.get("fbx_enabled"): - self.log.debug("fbx extractor has been disable.." - "Skipping the action...") + if not self.is_active(instance.data): return - # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) @@ -36,14 +34,15 @@ class ExtractRigFBX(publish.Extractor): # The export requires forward slashes because we need # to format it into a string in a mel expression - path = path.replace('\\', '/') + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_set = instance.data.get("skeleton_mesh", []) - self.log.debug("Extracting FBX to: {0}".format(path)) + instance.data["constraints"] = True - control_rigs = instance.data.get("control_rigs", []) - skeletal_mesh = instance.data.get("skeleton_mesh", []) - members = control_rigs + skeletal_mesh - self._to_extract(instance, path, members) + fbx_exporter.set_options_from_instance(instance) + + # Export + fbx_exporter.export(out_set, path) if "representations" not in instance.data: instance.data["representations"] = [] @@ -57,36 +56,3 @@ class ExtractRigFBX(publish.Extractor): instance.data["representations"].append(representation) self.log.debug("Extract FBX successful to: {0}".format(path)) - if skeletal_mesh: - self._to_extract(instance, path, skeletal_mesh) - representation = { - 'name': 'fbxanim', - 'ext': 'fbx', - 'files': filename, - "stagingDir": staging_dir, - "outputName": "fbxanim" - } - instance.data["representations"].append(representation) - self.log.debug( - "Extract animated FBX successful to: {0}".format(path)) - - def _to_extract(self, instance, path, members): - fbx_exporter = fbx.FBXExtractor(log=self.log) - control_rigs = instance.data.get("control_rigs", []) - skeletal_mesh = instance.data.get("skeleton_mesh", []) - static_sets = control_rigs + skeletal_mesh - if members == static_sets: - instance.data["constraints"] = True - instance.data["referencedAssetsContent"] = True - if members == skeletal_mesh: - instance.data["constraints"] = True - instance.data["referencedAssetsContent"] = True - instance.data["animationOnly"] = True - - fbx_exporter.set_options_from_instance(instance) - - # Export - with maintained_selection(): - fbx_exporter.export(members, path) - cmds.select(members, r=1, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 7b5392f8f9..21d5097fd2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -80,6 +80,9 @@ class ValidateRigContents(pyblish.api.InstancePlugin): % invalid_geometry) error = True + invalid = self.validate_skeleton_sets(instance) + if invalid: + error = True if error: raise PublishValidationError( "Invalid rig content. See log for details.") @@ -91,7 +94,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_set + set_members: list of nodes of the controls_SET hierarchy: list of nodes which reside under the root node Returns: @@ -118,7 +121,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_set + set_members: list of nodes of the controls_SET hierarchy: list of nodes which reside under the root node Returns: @@ -132,3 +135,62 @@ class ValidateRigContents(pyblish.api.InstancePlugin): invalid.append(node) return invalid + + + def validate_skeleton_sets(self, instance): + objectsets = ("skeletonAnim_SET", "skeletonMesh_SET") + missing = [obj for obj in objectsets if obj not in instance] + if missing: + self.log.debug("%s is missing %s" % (instance, missing)) + + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + self.log.debug("Skipping empty instance...") + return + # Ensure contents in sets and retrieve long path for all objects + output_content = cmds.sets( + "skeletonMesh_SET", query=True) or [] + output_content = cmds.ls(output_content, long=True) + + controls_content = cmds.sets( + "skeletonAnim_SET", query=True) or [] + controls_content = cmds.ls(controls_content, long=True) + + # Validate members are inside the hierarchy from root node + root_node = cmds.ls(set_members, assemblies=True) + hierarchy = cmds.listRelatives(root_node, allDescendents=True, + fullPath=True) + hierarchy = set(hierarchy) + + invalid_hierarchy = [] + if output_content: + for node in output_content: + if node not in hierarchy: + invalid_hierarchy.append(node) + invalid_geometry = self.validate_geometry(output_content) + if controls_content: + for node in controls_content: + if node not in hierarchy: + invalid_hierarchy.append(node) + invalid_controls = self.validate_controls(controls_content) + + error = False + if invalid_hierarchy: + self.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + + if invalid_controls: + self.log.error("Only transforms can be part of the controls_SET." + "\n%s" % invalid_controls) + error = True + + if invalid_geometry: + self.log.error("Only meshes can be part of the out_SET\n%s" + % invalid_geometry) + error = True + + return error diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 7bbf4257ab..ae9d9b51d2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -61,7 +61,10 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): controllers_sets = [i for i in instance if i == "controls_SET"] controls = cmds.sets(controllers_sets, query=True) assert controls, "Must have 'controls_SET' in rig instance" - + skeletonAnim_sets = [i for i in instance if i == "skeletonAnim_SET"] + if skeletonAnim_sets: + skeleton_controls = cmds.sets(skeletonAnim_sets, query=True) + controls += skeleton_controls # Ensure all controls are within the top group lookup = set(instance[:]) assert all(control in lookup for control in cmds.ls(controls, @@ -184,6 +187,9 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): # Use a single undo chunk with undo_chunk(): controls = cmds.sets("controls_SET", query=True) + anim_skeleton = cmds.sets("skeletonAnim_SET", query=True) + if anim_skeleton: + controls = controls + anim_skeleton for control in controls: # Lock visibility diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index 842c1de01b..eae75089fc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -56,7 +56,8 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] + controllers_sets = [i for i in instance + if i == "controls_SET"] if not controllers_sets: return [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 39f0941faa..05d7bfad64 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -47,7 +47,21 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): invalid = [] - out_set = next(x for x in instance if x.endswith("out_SET")) + out_set_invalid = cls.get_invalid_not_by_sets(instance) + if out_set_invalid: + invalid += out_set_invalid + + skeletonmesh_invalid = cls.get_invalid_not_by_sets( + instance, set_name="skeletonMesh_SET") + if skeletonmesh_invalid: + invalid += skeletonmesh_invalid + + return invalid + + @classmethod + def get_invalid_not_by_sets(cls, instance, set_name="out_SET"): + invalid = [] + out_set = next(x for x in instance if x.endswith(set_name)) members = cmds.sets(out_set, query=True) shapes = cmds.ls(members, dag=True, @@ -55,7 +69,8 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): shapes=True, long=True, noIntermediate=True) - + if not shapes: + return for shape in shapes: sibling_id = lib.get_id_from_sibling( shape, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index cbc750bace..9e81b1223a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -40,17 +40,23 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance, compute=False): invalid_matches = cls.get_invalid_matches(instance, compute=compute) + + invalid_skeleton_matches = cls.get_invalid_matches( + instance, compute=compute, set_name="skeletonMesh_SET") + invalid_matches.update(invalid_skeleton_matches) return list(invalid_matches.keys()) @classmethod - def get_invalid_matches(cls, instance, compute=False): + def get_invalid_matches(cls, instance, compute=False, set_name="out_SET"): invalid = {} if compute: - out_set = next(x for x in instance if "out_SET" in x) + out_set = next(x for x in instance if set_name in x) instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) + if not instance_nodes: + return for node in instance_nodes: shapes = cmds.listRelatives(node, shapes=True, fullPath=True) if shapes: From 2e36f7fc4723d27bbfcc40021a1e4b55051c3166 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 7 Sep 2023 20:48:19 +0800 Subject: [PATCH 047/327] hound --- openpype/hosts/maya/plugins/create/create_rig.py | 1 - openpype/hosts/maya/plugins/publish/validate_rig_contents.py | 1 - 2 files changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index b4ff6fad07..459fbdab56 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -1,7 +1,6 @@ from maya import cmds from openpype.hosts.maya.api import plugin -from openpype.lib import BoolDef class CreateRig(plugin.MayaCreator): diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 21d5097fd2..276c22977e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -136,7 +136,6 @@ class ValidateRigContents(pyblish.api.InstancePlugin): return invalid - def validate_skeleton_sets(self, instance): objectsets = ("skeletonAnim_SET", "skeletonMesh_SET") missing = [obj for obj in objectsets if obj not in instance] From 684ce0fc7d4080d106a002f2cade2c038326c089 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 7 Sep 2023 17:40:01 +0200 Subject: [PATCH 048/327] :art: WIP on the creator --- .../plugins/create/create_multishot_layout.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 openpype/hosts/maya/plugins/create/create_multishot_layout.py diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py new file mode 100644 index 0000000000..fbd7172ac4 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -0,0 +1,36 @@ +from openpype.hosts.maya.api import plugin +from openpype.lib import BoolDef +from openpype import AYON_SERVER_ENABLED +from ayon_api import get_folder_by_name + + +class CreateMultishotLayout(plugin.MayaCreator): + """A grouped package of loaded content""" + + identifier = "io.openpype.creators.maya.multishotlayout" + label = "Multishot Layout" + family = "layout" + icon = "camera" + + def get_instance_attr_defs(self): + + return [ + BoolDef("groupLoadedAssets", + label="Group Loaded Assets", + tooltip="Enable this when you want to publish group of " + "loaded asset", + default=False) + ] + + def create(self, subset_name, instance_data, pre_create_data): + # TODO: get this needs to be switched to get_folder_by_path + # once the fork to pure AYON is done. + # WARNING: this will not work for projects where the asset name + # is not unique across the project until the switch mentioned + # above is done. + current_folder = get_folder_by_name(instance_data["asset"]) + + +# blast this creator if Ayon server is not enabled +if not AYON_SERVER_ENABLED: + del CreateMultishotLayout From 9bbd457541071b729b048aea6215ce6f35448c0e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 8 Sep 2023 17:16:33 +0800 Subject: [PATCH 049/327] big roy's comment on separating validators of skeleton set from the mandatory set --- .../plugins/publish/validate_rig_contents.py | 67 +----- .../publish/validate_rig_controllers.py | 24 +- ...idate_rig_controllers_arnold_attributes.py | 7 - .../publish/validate_rig_out_set_node_ids.py | 7 +- .../publish/validate_rig_output_ids.py | 19 +- .../publish/validate_skeleton_rig_content.py | 138 +++++++++++ .../validate_skeleton_rig_controller.py | 222 ++++++++++++++++++ .../validate_skeleton_rig_out_set_node_ids.py | 90 +++++++ .../validate_skeleton_rig_output_ids.py | 126 ++++++++++ 9 files changed, 581 insertions(+), 119 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index ad8a0a23c2..23f031a5db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -96,9 +96,6 @@ class ValidateRigContents(pyblish.api.InstancePlugin): % invalid_geometry) error = True - invalid = self.validate_skeleton_sets(instance) - if invalid: - error = True if error: raise PublishValidationError( "Invalid rig content. See log for details.") @@ -110,7 +107,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_SET + set_members: list of nodes of the controls_set hierarchy: list of nodes which reside under the root node Returns: @@ -137,7 +134,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_SET + set_members: list of nodes of the controls_set hierarchy: list of nodes which reside under the root node Returns: @@ -151,63 +148,3 @@ class ValidateRigContents(pyblish.api.InstancePlugin): invalid.append(node) return invalid - - def validate_skeleton_sets(self, instance): - objectsets = ["skeletonAnim_SET", "skeletonMesh_SET"] - missing = [obj for obj in objectsets if obj not in instance] - if missing: - self.log.debug("%s is missing %s" % (instance, missing)) - - controls_set = instance.data["rig_sets"]["skeletonAnim_SET"] - out_set = instance.data["rig_sets"]["skeletonMesh_SET"] - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - self.log.debug("Skipping empty instance...") - return - # Ensure contents in sets and retrieve long path for all objects - output_content = cmds.sets( - out_set, query=True) or [] - output_content = cmds.ls(output_content, long=True) - - controls_content = cmds.sets( - controls_set, query=True) or [] - controls_content = cmds.ls(controls_content, long=True) - - # Validate members are inside the hierarchy from root node - root_node = cmds.ls(set_members, assemblies=True) - hierarchy = cmds.listRelatives(root_node, allDescendents=True, - fullPath=True) - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - if output_content: - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - invalid_geometry = self.validate_geometry(output_content) - if controls_content: - for node in controls_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - invalid_controls = self.validate_controls(controls_content) - - error = False - if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " - "while they are set up for publishing." - "\n%s" % invalid_hierarchy) - error = True - - if invalid_controls: - self.log.error("Only transforms can be part of the controls_SET." - "\n%s" % invalid_controls) - error = True - - if invalid_geometry: - self.log.error("Only meshes can be part of the out_SET\n%s" - % invalid_geometry) - error = True - - return error diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 266d98a433..a3828f871b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -76,20 +76,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): "All controls must be inside the rig's group." ) return [controls_set] - skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") - if not skeleton_set: - cls.log.info( - "No 'skeletonAnim_SET' in rig instance" - ) - skeleton_controls = cmds.sets(skeleton_set, query=True) - if not all(control in lookup for control in cmds.ls(skeleton_controls, - long=True)): - cls.log.error( - "All controls must be inside the rig's group." - ) - return [skeleton_controls] - controls += skeleton_controls # Validate all controls has_connections = list() has_unlocked_visibility = list() @@ -209,19 +196,10 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): "instance: {}".format(instance) ) return - skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") - if not skeleton_set: - cls.log.error( - "Unable to repair because no 'skeletonAnim_SET' found in rig " - "instance: {}".format(instance) - ) - return + # Use a single undo chunk with undo_chunk(): controls = cmds.sets(controls_set, query=True) - if skeleton_set: - skeleton_controls = cmds.sets(skeleton_set, query=True) - controls += skeleton_controls for control in controls: # Lock visibility diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index ec7fecf78a..03f6a5f1ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -63,13 +63,6 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin): controls = cmds.sets(controls_set, query=True) or [] if not controls: return [] - skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") - if not skeleton_set: - return [] - - skeleton_controls = cmds.sets(skeleton_set, query=True) or [] - if skeleton_controls: - controls += skeleton_controls shapes = cmds.ls(controls, dag=True, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index a0d477b698..fbd510c683 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -49,10 +49,6 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): out_set = instance.data["rig_sets"].get("out_SET") if not out_set: return [] - skeletonMesh_set = instance.data["rig_sets"].get( - "skeletonMesh_SET") - if skeletonMesh_set: - out_set += skeletonMesh_set invalid = [] members = cmds.sets(out_set, query=True) @@ -62,8 +58,7 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): shapes=True, long=True, noIntermediate=True) - if not shapes: - return + for shape in shapes: sibling_id = lib.get_id_from_sibling( shape, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index 5b3566a115..24fb36eb8b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -40,14 +40,10 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance, compute=False): invalid_matches = cls.get_invalid_matches(instance, compute=compute) - - invalid_skeleton_matches = cls.get_invalid_matches( - instance, compute=compute, set_name="skeletonMesh_SET") - invalid_matches.update(invalid_skeleton_matches) return list(invalid_matches.keys()) @classmethod - def get_invalid_matches(cls, instance, compute=False, set_name="out_SET"): + def get_invalid_matches(cls, instance, compute=False): invalid = {} if compute: @@ -57,20 +53,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): return invalid instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) - - skeletonMesh_set = instance.data["rig_sets"].get( - "skeletonMesh_SET") - if not skeletonMesh_set: - instance.data["mismatched_output_ids"] = invalid - return invalid - else: - skeletonMesh_nodes = cmds.sets( - skeletonMesh_set, query=True, nodesOnly=True) - instance_nodes += skeletonMesh_nodes - instance_nodes = cmds.ls(instance_nodes, long=True) - if not instance_nodes: - return for node in instance_nodes: shapes = cmds.listRelatives(node, shapes=True, fullPath=True) if shapes: diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py new file mode 100644 index 0000000000..8e0a998a1d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -0,0 +1,138 @@ +import pyblish.api +from maya import cmds + +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder +) + + +class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): + """Ensure skeleton rigs contains pipeline-critical content + + The rigs optionally contain at least two object sets: + "skeletonAnim_SET" - Set of only bone hierarchies + "skeletonMesh_SET" - Set of all cacheable meshes + + """ + + order = ValidateContentsOrder + label = "Rig Contents" + hosts = ["maya"] + families = ["rig"] + + accepted_output = ["mesh", "transform"] + accepted_controllers = ["transform"] + + def process(self, instance): + + objectsets = ["skeletonAnim_SET", "skeletonMesh_SET"] + missing = [obj for obj in objectsets if obj not in instance] + if missing: + self.log.debug("%s is missing %s" % (instance, missing)) + return + + controls_set = instance.data["rig_sets"]["skeletonAnim_SET"] + out_set = instance.data["rig_sets"]["skeletonMesh_SET"] + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + self.log.debug("Skipping empty instance...") + return + # Ensure contents in sets and retrieve long path for all objects + output_content = cmds.sets( + out_set, query=True) or [] + output_content = cmds.ls(output_content, long=True) + + controls_content = cmds.sets( + controls_set, query=True) or [] + controls_content = cmds.ls(controls_content, long=True) + + # Validate members are inside the hierarchy from root node + root_node = cmds.ls(set_members, assemblies=True) + hierarchy = cmds.listRelatives(root_node, allDescendents=True, + fullPath=True) + hierarchy = set(hierarchy) + + invalid_hierarchy = [] + if output_content: + for node in output_content: + if node not in hierarchy: + invalid_hierarchy.append(node) + invalid_geometry = self.validate_geometry(output_content) + if controls_content: + for node in controls_content: + if node not in hierarchy: + invalid_hierarchy.append(node) + invalid_controls = self.validate_controls(controls_content) + + error = False + if invalid_hierarchy: + self.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + + if invalid_controls: + self.log.error("Only transforms can be part of the skeletonAnim_SET." + "\n%s" % invalid_controls) + error = True + + if invalid_geometry: + self.log.error("Only meshes can be part of the skeletonMesh_SET\n%s" + % invalid_geometry) + error = True + + if error: + raise PublishValidationError( + "Invalid rig content. See log for details.") + + def validate_geometry(self, set_members): + """Check if the out set passes the validations + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the controls_SET + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate all shape types + invalid = [] + shapes = cmds.listRelatives(set_members, + allDescendents=True, + shapes=True, + fullPath=True) or [] + all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) + for shape in all_shapes: + if cmds.nodeType(shape) not in self.accepted_output: + invalid.append(shape) + + return invalid + + def validate_controls(self, set_members): + """Check if the controller set passes the validations + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the controls_SET + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate control types + invalid = [] + for node in set_members: + if cmds.nodeType(node) not in self.accepted_controllers: + invalid.append(node) + + return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py new file mode 100644 index 0000000000..82e0d542ca --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py @@ -0,0 +1,222 @@ +from maya import cmds + +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, + PublishValidationError +) +import openpype.hosts.maya.api.action +from openpype.hosts.maya.api.lib import undo_chunk + + +class ValidateSkeletonRigControllers(pyblish.api.InstancePlugin): + """Validate rig controller for skeletonAnim_SET + + Controls must have the transformation attributes on their default + values of translate zero, rotate zero and scale one when they are + unlocked attributes. + + Unlocked keyable attributes may not have any incoming connections. If + these connections are required for the rig then lock the attributes. + + The visibility attribute must be locked. + + Note that `repair` will: + - Lock all visibility attributes + - Reset all default values for translate, rotate, scale + - Break all incoming connections to keyable attributes + + """ + order = ValidateContentsOrder + 0.05 + label = "Rig Controllers" + hosts = ["maya"] + families = ["rig"] + actions = [RepairAction, + openpype.hosts.maya.api.action.SelectInvalidAction] + + # Default controller values + CONTROLLER_DEFAULTS = { + "translateX": 0, + "translateY": 0, + "translateZ": 0, + "rotateX": 0, + "rotateY": 0, + "rotateZ": 0, + "scaleX": 1, + "scaleY": 1, + "scaleZ": 1 + } + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + '{} failed, see log information'.format(self.label) + ) + + @classmethod + def get_invalid(cls, instance): + skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") + if not skeleton_set: + cls.log.info( + "No 'skeletonAnim_SET' in rig instance" + ) + return + controls = cmds.sets(skeleton_set, query=True) + lookup = set(instance[:]) + if not all(control in lookup for control in cmds.ls(controls, + long=True)): + cls.log.error( + "All controls must be inside the rig's group." + ) + return [controls] + # Validate all controls + has_connections = list() + has_unlocked_visibility = list() + has_non_default_values = list() + for control in controls: + if cls.get_connected_attributes(control): + has_connections.append(control) + + # check if visibility is locked + attribute = "{}.visibility".format(control) + locked = cmds.getAttr(attribute, lock=True) + if not locked: + has_unlocked_visibility.append(control) + + if cls.get_non_default_attributes(control): + has_non_default_values.append(control) + + if has_connections: + cls.log.error("Controls have input connections: " + "%s" % has_connections) + + if has_non_default_values: + cls.log.error("Controls have non-default values: " + "%s" % has_non_default_values) + + if has_unlocked_visibility: + cls.log.error("Controls have unlocked visibility " + "attribute: %s" % has_unlocked_visibility) + + invalid = [] + if (has_connections or + has_unlocked_visibility or + has_non_default_values): + invalid = set() + invalid.update(has_connections) + invalid.update(has_non_default_values) + invalid.update(has_unlocked_visibility) + invalid = list(invalid) + cls.log.error("Invalid rig controllers. See log for details.") + + return invalid + + @classmethod + def get_non_default_attributes(cls, control): + """Return attribute plugs with non-default values + + Args: + control (str): Name of control node. + + Returns: + list: The invalid plugs + + """ + + invalid = [] + for attr, default in cls.CONTROLLER_DEFAULTS.items(): + if cmds.attributeQuery(attr, node=control, exists=True): + plug = "{}.{}".format(control, attr) + + # Ignore locked attributes + locked = cmds.getAttr(plug, lock=True) + if locked: + continue + + value = cmds.getAttr(plug) + if value != default: + cls.log.warning("Control non-default value: " + "%s = %s" % (plug, value)) + invalid.append(plug) + + return invalid + + @staticmethod + def get_connected_attributes(control): + """Return attribute plugs with incoming connections. + + This will also ensure no (driven) keys on unlocked keyable attributes. + + Args: + control (str): Name of control node. + + Returns: + list: The invalid plugs + + """ + import maya.cmds as mc + + # Support controls without any attributes returning None + attributes = mc.listAttr(control, keyable=True, scalar=True) or [] + invalid = [] + for attr in attributes: + plug = "{}.{}".format(control, attr) + + # Ignore locked attributes + locked = cmds.getAttr(plug, lock=True) + if locked: + continue + + # Ignore proxy connections. + if (cmds.addAttr(plug, query=True, exists=True) and + cmds.addAttr(plug, query=True, usedAsProxy=True)): + continue + + # Check for incoming connections + if cmds.listConnections(plug, source=True, destination=False): + invalid.append(plug) + + return invalid + + @classmethod + def repair(cls, instance): + skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") + if not skeleton_set: + cls.log.error( + "Unable to repair because no 'skeletonAnim_SET' found in rig " + "instance: {}".format(instance) + ) + return + # Use a single undo chunk + with undo_chunk(): + controls = cmds.sets(skeleton_set, query=True) + for control in controls: + # Lock visibility + attr = "{}.visibility".format(control) + locked = cmds.getAttr(attr, lock=True) + if not locked: + cls.log.info("Locking visibility for %s" % control) + cmds.setAttr(attr, lock=True) + + # Remove incoming connections + invalid_plugs = cls.get_connected_attributes(control) + if invalid_plugs: + for plug in invalid_plugs: + cls.log.info("Breaking input connection to %s" % plug) + source = cmds.listConnections(plug, + source=True, + destination=False, + plugs=True)[0] + cmds.disconnectAttr(source, plug) + + # Reset non-default values + invalid_plugs = cls.get_non_default_attributes(control) + if invalid_plugs: + for plug in invalid_plugs: + attr = plug.split(".")[-1] + default = cls.CONTROLLER_DEFAULTS[attr] + cls.log.info("Setting %s to %s" % (plug, default)) + cmds.setAttr(plug, default) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py new file mode 100644 index 0000000000..b682c8e953 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py @@ -0,0 +1,90 @@ +import maya.cmds as cmds + +import pyblish.api + +import openpype.hosts.maya.api.action +from openpype.hosts.maya.api import lib +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError +) + + +class ValidateSkeletonRigOutSetNodeIds(pyblish.api.InstancePlugin): + """Validate if deformed shapes have related IDs to the original shapes + from skeleton set. + + When a deformer is applied in the scene on a referenced mesh that already + had deformers then Maya will create a new shape node for the mesh that + does not have the original id. This validator checks whether the ids are + valid on all the shape nodes in the instance. + + """ + + order = ValidateContentsOrder + families = ["rig"] + hosts = ['maya'] + label = 'Rig Out Set Node Ids' + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, + RepairAction + ] + allow_history_only = False + + def process(self, instance): + """Process all meshes""" + + # Ensure all nodes have a cbId and a related ID to the original shapes + # if a deformer has been created on the shape + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Nodes found with mismatching IDs: {0}".format(invalid) + ) + + @classmethod + def get_invalid(cls, instance): + """Get all nodes which do not match the criteria""" + + skeletonMesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeletonMesh_set: + return [] + + invalid = [] + members = cmds.sets(skeletonMesh_set, query=True) + shapes = cmds.ls(members, + dag=True, + leaf=True, + shapes=True, + long=True, + noIntermediate=True) + if not shapes: + return + for shape in shapes: + sibling_id = lib.get_id_from_sibling( + shape, + history_only=cls.allow_history_only + ) + if sibling_id: + current_id = lib.get_id(shape) + if current_id != sibling_id: + invalid.append(shape) + + return invalid + + @classmethod + def repair(cls, instance): + + for node in cls.get_invalid(instance): + # Get the original id from sibling + sibling_id = lib.get_id_from_sibling( + node, + history_only=cls.allow_history_only + ) + if not sibling_id: + cls.log.error("Could not find ID in siblings for '%s'", node) + continue + + lib.set_id(node, sibling_id, overwrite=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py new file mode 100644 index 0000000000..76f058a94b --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py @@ -0,0 +1,126 @@ +from collections import defaultdict + +from maya import cmds + +import pyblish.api + +import openpype.hosts.maya.api.action +from openpype.hosts.maya.api.lib import get_id, set_id +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError +) + + +def get_basename(node): + """Return node short name without namespace""" + return node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] + + +class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): + """Validate rig output ids from the skeleton sets. + + Ids must share the same id as similarly named nodes in the scene. This is + to ensure the id from the model is preserved through animation. + + """ + order = ValidateContentsOrder + 0.05 + label = "Rig Output Ids" + hosts = ["maya"] + families = ["rig"] + actions = [RepairAction, + openpype.hosts.maya.api.action.SelectInvalidAction] + + def process(self, instance): + invalid = self.get_invalid(instance, compute=True) + if invalid: + raise PublishValidationError("Found nodes with mismatched IDs.") + + @classmethod + def get_invalid(cls, instance, compute=False): + invalid_matches = cls.get_invalid_matches(instance, compute=compute) + + invalid_skeleton_matches = cls.get_invalid_matches( + instance, compute=compute, set_name="skeletonMesh_SET") + invalid_matches.update(invalid_skeleton_matches) + return list(invalid_matches.keys()) + + @classmethod + def get_invalid_matches(cls, instance, compute=False): + invalid = {} + + if compute: + skeletonMesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeletonMesh_set: + instance.data["mismatched_output_ids"] = invalid + return invalid + + instance_nodes = cmds.sets( + skeletonMesh_set, query=True, nodesOnly=True) + + instance_nodes = cmds.ls(instance_nodes, long=True) + if not instance_nodes: + return + for node in instance_nodes: + shapes = cmds.listRelatives(node, shapes=True, fullPath=True) + if shapes: + instance_nodes.extend(shapes) + + scene_nodes = cmds.ls(type="transform", long=True) + scene_nodes += cmds.ls(type="mesh", long=True) + scene_nodes = set(scene_nodes) - set(instance_nodes) + + scene_nodes_by_basename = defaultdict(list) + for node in scene_nodes: + basename = get_basename(node) + scene_nodes_by_basename[basename].append(node) + + for instance_node in instance_nodes: + basename = get_basename(instance_node) + if basename not in scene_nodes_by_basename: + continue + + matches = scene_nodes_by_basename[basename] + + ids = set(get_id(node) for node in matches) + ids.add(get_id(instance_node)) + + if len(ids) > 1: + cls.log.error( + "\"{}\" id mismatch to: {}".format( + instance_node, matches + ) + ) + invalid[instance_node] = matches + + instance.data["mismatched_output_ids"] = invalid + else: + invalid = instance.data["mismatched_output_ids"] + + return invalid + + @classmethod + def repair(cls, instance): + invalid_matches = cls.get_invalid_matches(instance) + + multiple_ids_match = [] + for instance_node, matches in invalid_matches.items(): + ids = set(get_id(node) for node in matches) + + # If there are multiple scene ids matched, and error needs to be + # raised for manual correction. + if len(ids) > 1: + multiple_ids_match.append({"node": instance_node, + "matches": matches}) + continue + + id_to_set = next(iter(ids)) + set_id(instance_node, id_to_set, overwrite=True) + + if multiple_ids_match: + raise PublishValidationError( + "Multiple matched ids found. Please repair manually: " + "{}".format(multiple_ids_match) + ) From 35e287a57d99bb04aa25ca5f06048b8326cc618e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 8 Sep 2023 17:19:32 +0800 Subject: [PATCH 050/327] big roy's comment --- .../plugins/publish/validate_skeleton_rig_out_set_node_ids.py | 2 +- .../maya/plugins/publish/validate_skeleton_rig_output_ids.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py index b682c8e953..d62bd68b15 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py @@ -61,7 +61,7 @@ class ValidateSkeletonRigOutSetNodeIds(pyblish.api.InstancePlugin): long=True, noIntermediate=True) if not shapes: - return + return [] for shape in shapes: sibling_id = lib.get_id_from_sibling( shape, diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py index 76f058a94b..bea18977f3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py @@ -62,7 +62,7 @@ class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): instance_nodes = cmds.ls(instance_nodes, long=True) if not instance_nodes: - return + return {} for node in instance_nodes: shapes = cmds.listRelatives(node, shapes=True, fullPath=True) if shapes: From cce6bf2e4299f61118eb5ca479f141e50704be9e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 8 Sep 2023 17:24:01 +0800 Subject: [PATCH 051/327] hound --- .../maya/plugins/publish/validate_skeleton_rig_content.py | 8 ++++---- .../plugins/publish/validate_skeleton_rig_output_ids.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 8e0a998a1d..b70d8e6f3f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -75,13 +75,13 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): error = True if invalid_controls: - self.log.error("Only transforms can be part of the skeletonAnim_SET." - "\n%s" % invalid_controls) + self.log.error("Only transforms can be part of the " + "skeletonAnim_SET. \n%s" % invalid_controls) error = True if invalid_geometry: - self.log.error("Only meshes can be part of the skeletonMesh_SET\n%s" - % invalid_geometry) + self.log.error("Only meshes can be part of the " + "skeletonMesh_SET\n%s" % invalid_geometry) error = True if error: diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py index bea18977f3..0b936d35f4 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py @@ -58,7 +58,7 @@ class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): return invalid instance_nodes = cmds.sets( - skeletonMesh_set, query=True, nodesOnly=True) + skeletonMesh_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) if not instance_nodes: From cfb4ceb5d41ea59f587555f748da902bae46dc64 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 8 Sep 2023 17:34:45 +0800 Subject: [PATCH 052/327] big roy's comment on rig.fbx families --- .../maya/plugins/publish/collect_fbx_animation.py | 1 + .../maya/plugins/publish/collect_rig_for_fbx.py | 1 + .../maya/plugins/publish/extract_fbx_animation.py | 2 +- .../hosts/maya/plugins/publish/extract_rig_fbx.py | 2 +- .../plugins/publish/validate_skeleton_rig_content.py | 12 ++++++++---- .../publish/validate_skeleton_rig_controller.py | 4 ++-- .../validate_skeleton_rig_out_set_node_ids.py | 4 ++-- .../publish/validate_skeleton_rig_output_ids.py | 4 ++-- openpype/plugins/publish/integrate.py | 1 + 9 files changed, 19 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index e1b2fc0b7b..fb045973b6 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -20,6 +20,7 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonanim_set") ] if skeleton_sets: + instance.data["families"].append("rig.fbx") for skeleton_set in skeleton_sets: skeleton_content = cmds.ls( cmds.sets(skeleton_set, query=True), long=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index 6ade7451d6..853dcbb259 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -24,6 +24,7 @@ class CollectRigFbx(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonmesh_set") ] if skeleton_sets or skeleton_mesh_sets: + instance.data["families"].append("rig.fbx") instance.data["skeleton_mesh"] = [] for skeleton_set in skeleton_sets: skeleton_content = cmds.ls( diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 111a202f82..8c540a0101 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -22,7 +22,7 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" - families = ["animation"] + families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 2aa02a21c3..ebaf8a83ca 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -22,7 +22,7 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Rig (FBX)" - families = ["rig"] + families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index b70d8e6f3f..0406b00ec6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -17,9 +17,9 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): """ order = ValidateContentsOrder - label = "Rig Contents" + label = "Skeleton Rig Contents" hosts = ["maya"] - families = ["rig"] + families = ["rig.fbx"] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] @@ -27,9 +27,13 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): def process(self, instance): objectsets = ["skeletonAnim_SET", "skeletonMesh_SET"] - missing = [obj for obj in objectsets if obj not in instance] + missing = [ + key for key in objectsets if key not in instance.data["rig_sets"] + ] if missing: - self.log.debug("%s is missing %s" % (instance, missing)) + self.log.debug( + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) return controls_set = instance.data["rig_sets"]["skeletonAnim_SET"] diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py index 82e0d542ca..a31d13bcec 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py @@ -30,9 +30,9 @@ class ValidateSkeletonRigControllers(pyblish.api.InstancePlugin): """ order = ValidateContentsOrder + 0.05 - label = "Rig Controllers" + label = "Skeleton Rig Controllers" hosts = ["maya"] - families = ["rig"] + families = ["rig.fbx"] actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py index d62bd68b15..73ad12f422 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py @@ -23,9 +23,9 @@ class ValidateSkeletonRigOutSetNodeIds(pyblish.api.InstancePlugin): """ order = ValidateContentsOrder - families = ["rig"] + families = ["rig.fbx"] hosts = ['maya'] - label = 'Rig Out Set Node Ids' + label = 'Skeleton Rig Out Set Node Ids' actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py index 0b936d35f4..0d1e702749 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py @@ -26,9 +26,9 @@ class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): """ order = ValidateContentsOrder + 0.05 - label = "Rig Output Ids" + label = "Skeleton Rig Output Ids" hosts = ["maya"] - families = ["rig"] + families = ["rig.fbx"] actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 7e48155b9e..2e122b652e 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -105,6 +105,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "review", "rendersetup", "rig", + "rig.fbx", "plate", "look", "audio", From 3b6079f74374659a38bfc9b725cabbf26858b05f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 8 Sep 2023 17:58:38 +0800 Subject: [PATCH 053/327] remove rig.fbx --- openpype/plugins/publish/integrate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 2e122b652e..7e48155b9e 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -105,7 +105,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "review", "rendersetup", "rig", - "rig.fbx", "plate", "look", "audio", From 7f2e5e8fa9fa41f41914fb4f4d43048de0c7beb1 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 8 Sep 2023 18:49:12 +0200 Subject: [PATCH 054/327] :recycle: multishot layout creator WIP still need to add Task information to created layouts --- .../plugins/create/create_multishot_layout.py | 150 ++++++++++++++++-- 1 file changed, 138 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index fbd7172ac4..706203bdab 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -1,20 +1,68 @@ -from openpype.hosts.maya.api import plugin -from openpype.lib import BoolDef +from ayon_api import get_folder_by_name, get_folder_by_path, get_folders +from maya import cmds # noqa: F401 + from openpype import AYON_SERVER_ENABLED -from ayon_api import get_folder_by_name +from openpype.client import get_assets +from openpype.hosts.maya.api import plugin +from openpype.lib import BoolDef, EnumDef +from openpype.pipeline import ( + Creator, + get_current_asset_name, + get_current_project_name +) +from openpype.pipeline.create import CreatorError class CreateMultishotLayout(plugin.MayaCreator): - """A grouped package of loaded content""" + """Create a multishot layout in the Maya scene. + This creator will create a Camera Sequencer in the Maya scene based on + the shots found under the specified folder. The shots will be added to + the sequencer in the order of their clipIn and clipOut values. For each + shot a Layout will be created. + + """ identifier = "io.openpype.creators.maya.multishotlayout" label = "Multishot Layout" family = "layout" - icon = "camera" + icon = "project-diagram" - def get_instance_attr_defs(self): + def get_pre_create_attr_defs(self): + # Present artist with a list of parents of the current context + # to choose from. This will be used to get the shots under the + # selected folder to create the Camera Sequencer. + + """ + Todo: get this needs to be switched to get_folder_by_path + once the fork to pure AYON is done. + + Warning: this will not work for projects where the asset name + is not unique across the project until the switch mentioned + above is done. + """ + + current_folder = get_folder_by_name( + project_name=get_current_project_name(), + folder_name=get_current_asset_name(), + ) + + items_with_label = [ + dict(label=p if p != current_folder["name"] else f"{p} (current)", + value=str(p)) + for p in current_folder["path"].split("/") + ] + + items_with_label.insert(0, + dict(label=f"{self.project_name} " + "(shots directly under the project)", + value=None)) return [ + EnumDef("shotParent", + default=current_folder["name"], + label="Shot Parent Folder", + items=items_with_label, + ), BoolDef("groupLoadedAssets", label="Group Loaded Assets", tooltip="Enable this when you want to publish group of " @@ -23,12 +71,90 @@ class CreateMultishotLayout(plugin.MayaCreator): ] def create(self, subset_name, instance_data, pre_create_data): - # TODO: get this needs to be switched to get_folder_by_path - # once the fork to pure AYON is done. - # WARNING: this will not work for projects where the asset name - # is not unique across the project until the switch mentioned - # above is done. - current_folder = get_folder_by_name(instance_data["asset"]) + shots = self.get_related_shots( + folder_path=pre_create_data["shotParent"] + ) + if not shots: + # There are no shot folders under the specified folder. + # We are raising an error here but in the future we might + # want to create a new shot folders by publishing the layouts + # and shot defined in the sequencer. Sort of editorial publish + # in side of Maya. + raise CreatorError("No shots found under the specified folder.") + + # Get layout creator + layout_creator_id = "io.openpype.creators.maya.layout" + layout_creator: Creator = self.create_context.creators.get( + layout_creator_id) + + # Get OpenPype style asset documents for the shots + op_asset_docs = get_assets( + self.project_name, [s["id"] for s in shots]) + for shot in shots: + # we are setting shot name to be displayed in the sequencer to + # `shot name (shot label)` if the label is set, otherwise just + # `shot name`. So far, labels are used only when the name is set + # with characters that are not allowed in the shot name. + if not shot["active"]: + continue + + shot_name = f"{shot['name']}%s" % ( + f" ({shot['label']})" if shot["label"] else "") + cmds.shot(sst=shot["attrib"]["clipIn"], + set=shot["attrib"]["clipOut"], + shotName=shot_name) + + # Create layout instance by the layout creator + layout_creator.create( + subset_name=layout_creator.get_subset_name( + self.get_default_variant(), + self.create_context.get_current_task_name(), + next( + asset_doc for asset_doc in op_asset_docs + if asset_doc["_id"] == shot["id"] + ), + self.project_name), + instance_data={ + "asset": shot["name"], + }, + pre_create_data={ + "groupLoadedAssets": pre_create_data["groupLoadedAssets"] + } + ) + + def get_related_shots(self, folder_path: str): + """Get all shots related to the current asset. + + Get all folders of type Shot under specified folder. + + Args: + folder_path (str): Path of the folder. + + Returns: + list: List of dicts with folder data. + + """ + # if folder_path is None, project is selected as a root + # and its name is used as a parent id + parent_id = [self.project_name] + if folder_path: + current_folder = get_folder_by_path( + project_name=self.project_name, + folder_path=folder_path, + ) + parent_id = [current_folder["id"]] + + # get all child folders of the current one + child_folders = get_folders( + project_name=self.project_name, + parent_ids=parent_id, + fields=[ + "attrib.clipIn", "attrib.clipOut", + "attrib.frameStart", "attrib.frameEnd", + "name", "label", "path", "folderType", "id" + ] + ) + return [f for f in child_folders if f["folderType"] == "Shot"] # blast this creator if Ayon server is not enabled From 5c3f12d51897b4522e9ce3a364e6aa3c71963a6d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 11 Sep 2023 20:19:40 +0800 Subject: [PATCH 055/327] make the validators optional --- .../defaults/project_settings/maya.json | 20 +++++++++ .../schemas/schema_maya_publish.json | 44 +++++++++++++++++-- .../maya/server/settings/publishers.py | 36 +++++++++++++++ server_addon/maya/server/version.py | 2 +- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 38f14ec022..2bc226c431 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1140,6 +1140,16 @@ "optional": false, "active": true }, + "ValidateSkeletonRigContents": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateSkeletonRigControllers": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateSkinclusterDeformerSet": { "enabled": true, "optional": false, @@ -1150,6 +1160,16 @@ "optional": false, "allow_history_only": false }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": false, + "optional": false, + "allow_history_only": false + }, + "ValidateSkeletonRigOutputIds": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index b115ee3faa..e8300282d7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -809,9 +809,47 @@ "key": "ValidateSkeletalMeshHierarchy", "label": "Validate Skeletal Mesh Top Node" }, - { + { + "key": "ValidateSkeletonRigContents", + "label": "ValidateSkeleton Rig Contents" + }, + { + "key": "ValidateSkeletonRigControllers", + "label": "Validate Skeleton Rig Controllers" + }, + { "key": "ValidateSkinclusterDeformerSet", "label": "Validate Skincluster Deformer Relationships" + }, + { + "key": "ValidateSkeletonRigOutputIds", + "label": "Validate Skeleton Rig Output Ids" + } + ] + }, + + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateRigOutSetNodeIds", + "label": "Validate Rig Out Set Node Ids", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "allow_history_only", + "label": "Allow history only" } ] }, @@ -819,8 +857,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ValidateRigOutSetNodeIds", - "label": "Validate Rig Out Set Node Ids", + "key": "ValidateSkeletonRigOutSetNodeIds", + "label": "Validate Skeleton Rig Out Set Node Ids", "is_group": true, "children": [ { diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index bd7ccdf4d5..6e3179b78e 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -660,14 +660,30 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Skeletal Mesh Top Node", ) + ValidateSkeletonRigContents: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Contents" + ) + ValidateSkeletonRigControllers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Controllers" + ) ValidateSkinclusterDeformerSet: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Skincluster Deformer Relationships", ) + ValidateSkeletonRigOutputIds: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Output Ids" + ) ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( default_factory=ValidateRigOutSetNodeIdsModel, title="Validate Rig Out Set Node Ids", ) + ValidateSkeletonRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( + default_factory=ValidateRigOutSetNodeIdsModel, + title="Validate Skeleton Rig Out Set Node Ids", + ) # Rig - END ValidateCameraAttributes: BasicValidateModel = Field( default_factory=BasicValidateModel, @@ -1163,6 +1179,16 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateSkeletonRigContents": { + "enabled": False, + "optional": True, + "active": True + }, + "ValidateSkeletonRigControllers": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateSkinclusterDeformerSet": { "enabled": True, "optional": False, @@ -1173,6 +1199,16 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "allow_history_only": False }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": False, + "optional": False, + "allow_history_only": False + }, + "ValidateSkeletonRigOutputIds": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateCameraAttributes": { "enabled": False, "optional": True, diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index e57ad00718..de699158fd 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.3" +__version__ = "0.1.4" From 09e797577b78650eb31e464311c0478564b8f130 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 11 Sep 2023 21:35:58 +0800 Subject: [PATCH 056/327] remove the irrelevant fbx output parameter --- openpype/hosts/maya/api/fbx.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 064ba00f08..000c723d37 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -64,8 +64,7 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "triangulate": bool, - "exportFileVersion": str + "triangulate": bool } @property @@ -106,8 +105,7 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False, - "exportFileVersion": "FBX201000" + "triangulate": False } def __init__(self, log=None): From 5506a89ad644e348be3b8fbb08714496b64f64f2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 11 Sep 2023 21:40:38 +0800 Subject: [PATCH 057/327] wrong family for collect fbx animation --- openpype/hosts/maya/plugins/publish/collect_fbx_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index fb045973b6..a9a13637f7 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -8,7 +8,7 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.2 label = "Collect Fbx Animation" - families = ["rig"] + families = ["animation"] def process(self, instance): frame = cmds.currentTime(query=True) From 4b27abfae2320e3324cccea63b82a2690097fab5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 11 Sep 2023 15:17:17 +0100 Subject: [PATCH 058/327] Implemented several suggestions from reviews Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: Oscar Domingo Co-authored-by: Roy Nieterau --- openpype/hosts/blender/api/colorspace.py | 8 ++++---- openpype/hosts/blender/plugins/create/create_render.py | 10 +++------- .../projects_schema/schema_project_blender.json | 2 +- .../blender/server/settings/render_settings.py | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/blender/api/colorspace.py b/openpype/hosts/blender/api/colorspace.py index 59deb514f8..0f504a3be0 100644 --- a/openpype/hosts/blender/api/colorspace.py +++ b/openpype/hosts/blender/api/colorspace.py @@ -12,12 +12,12 @@ class LayerMetadata(object): @attr.s class RenderProduct(object): - """Getting Colorspace as - Specific Render Product Parameter for submitting + """ + Getting Colorspace as Specific Render Product Parameter for submitting publish job. """ - colorspace = attr.ib() # colorspace - view = attr.ib() + colorspace = attr.ib() # colorspace + view = attr.ib() # OCIO view transform productName = attr.ib(default=None) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 62700cb55c..fa3cae6cc8 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -3,13 +3,11 @@ import os import bpy +from openpype.settings import get_project_settings from openpype.pipeline import ( get_current_project_name, + get_current_task_name, ) -from openpype.settings import ( - get_project_settings, -) -from openpype.pipeline import get_current_task_name from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -76,9 +74,7 @@ class CreateRenderlayer(plugin.Creator): instance (pyblish.api.Instance): The instance to publish. ext (str): The image format to render. """ - output_file = os.path.join(output_path, name) - - render_product = f"{output_file}.####" + render_product = f"{os.path.join(output_path, name)}.####" render_product = render_product.replace("\\", "/") return render_product diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index 8db57f49eb..a283a2ff5c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -140,7 +140,7 @@ "label": "Type", "type": "enum", "multiselection": false, - "defaults": "color", + "default": "COLOR", "enum_items": [ {"COLOR": "Color"}, {"VALUE": "Value"} diff --git a/server_addon/blender/server/settings/render_settings.py b/server_addon/blender/server/settings/render_settings.py index bef16328d6..7a47095d3c 100644 --- a/server_addon/blender/server/settings/render_settings.py +++ b/server_addon/blender/server/settings/render_settings.py @@ -57,7 +57,7 @@ class CustomPassesModel(BaseSettingsModel): attribute: str = Field("", title="Attribute name") value: str = Field( - "Color", + "COLOR", title="Type", enum_resolver=custom_passes_types_enum ) From 01282f3af797d2c3f879bd91de692965eb25ebdb Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 12 Sep 2023 11:47:48 +0100 Subject: [PATCH 059/327] Fix AOVs publish with multilayer EXR --- openpype/hosts/blender/plugins/create/create_render.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index fa3cae6cc8..84387ffb16 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -177,10 +177,15 @@ class CreateRenderlayer(plugin.Creator): # Create a new output node output = tree.nodes.new("CompositorNodeOutputFile") + aov_file_products = [] + if ext == "exr" and multilayer: output.layer_slots.clear() filepath = f"{name}{aov_sep}AOVs.####" output.base_path = os.path.join(output_path, filepath) + + aov_file_products.append( + ("AOVs", os.path.join(output_path, filepath))) else: output.file_slots.clear() output.base_path = output_path @@ -188,8 +193,6 @@ class CreateRenderlayer(plugin.Creator): image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format - aov_file_products = [] - # For each active render pass, we add a new socket to the output node # and link it for render_pass in passes: From 6f23755873fe66e69d549f4551717509a500c75f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 12 Sep 2023 19:48:36 +0800 Subject: [PATCH 060/327] make sure the rig family has published fbx and file version should be 2022 --- openpype/hosts/maya/api/fbx.py | 6 +++-- .../hosts/maya/plugins/create/create_rig.py | 2 +- .../plugins/publish/collect_fbx_animation.py | 8 +++---- .../plugins/publish/collect_rig_for_fbx.py | 23 +++++++++++-------- .../plugins/publish/extract_fbx_animation.py | 8 +++++-- .../maya/plugins/publish/extract_rig_fbx.py | 12 +++++++--- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 000c723d37..18b28f5154 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -64,7 +64,8 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "triangulate": bool + "triangulate": bool, + "FileVersion": str } @property @@ -105,7 +106,8 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False + "triangulate": False, + "fileVersion": "FBX202000" } def __init__(self, log=None): diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 459fbdab56..69c7787905 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -25,7 +25,7 @@ class CreateRig(plugin.MayaCreator): # change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) skeleton = cmds.sets( - name=subset_name + "skeletonAnim_SET", empty=True) + name=subset_name + "_skeletonAnim_SET", empty=True) skeleton_mesh = cmds.sets( name=subset_name + "_skeletonMesh_SET", empty=True) cmds.sets([controls, pointcache, diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index a9a13637f7..aef838223e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -16,13 +16,13 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): instance.data["frameEnd"] = frame skeleton_sets = [ - i for i in instance[:] + i for i in instance if i.lower().endswith("skeletonanim_set") ] if skeleton_sets: - instance.data["families"].append("rig.fbx") + instance.data["families"].append("animation.fbx") for skeleton_set in skeleton_sets: - skeleton_content = cmds.ls( - cmds.sets(skeleton_set, query=True), long=True) + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug(f"Collected Animated Skeleton Set: {skeleton_content}") if skeleton_content: instance.data["animated_skeleton"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index 853dcbb259..fe8d5ca8ef 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -15,25 +15,28 @@ class CollectRigFbx(pyblish.api.InstancePlugin): instance.data["frameStart"] = frame instance.data["frameEnd"] = frame skeleton_sets = [ - i for i in instance[:] + i for i in instance if i.lower().endswith("skeletonanim_set") ] skeleton_mesh_sets = [ - i for i in instance[:] + i for i in instance if i.lower().endswith("skeletonmesh_set") ] - if skeleton_sets or skeleton_mesh_sets: - instance.data["families"].append("rig.fbx") - instance.data["skeleton_mesh"] = [] + if not skeleton_sets and skeleton_mesh_sets: + self.log.debug("no skeleton_set or skeleton_mesh set was found....") + return + instance.data["skeleton_mesh"] = [] + if skeleton_sets: for skeleton_set in skeleton_sets: - skeleton_content = cmds.ls( - cmds.sets(skeleton_set, query=True), long=True) + skeleton_content = cmds.sets(skeleton_set, query=True) if skeleton_content: instance.data["animated_rigs"] += skeleton_content - + self.log.debug(f"Collected Skeleton Set: {skeleton_content}") + if skeleton_mesh_sets: + instance.data["families"].append("rig.fbx") for skeleton_mesh_set in skeleton_mesh_sets: - skeleton_mesh_content = cmds.ls( - cmds.sets(skeleton_mesh_set, query=True), long=True) + skeleton_mesh_content = cmds.sets(skeleton_mesh_set, query=True) if skeleton_mesh_content: instance.data["skeleton_mesh"] += skeleton_mesh_content + self.log.debug(f"Collected SkeletonMesh Set: {skeleton_mesh_content}") diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 8c540a0101..2ac4734d21 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -2,7 +2,6 @@ import os from maya import cmds # noqa -import maya.mel as mel # noqa import pyblish.api from openpype.pipeline import publish @@ -22,11 +21,16 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" - families = ["rig.fbx"] + families = ["animation"] def process(self, instance): if not self.is_active(instance.data): return + if "animation.fbx" not in instance.data["families"]: + self.log.debug("No object inside skeleton_set..Skipping...") + return + if not cmds.loadPlugin("fbxmaya", query=True): + cmds.loadPlugin("fbxmaya", quiet=True) # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index ebaf8a83ca..0df602fa29 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -2,7 +2,6 @@ import os from maya import cmds # noqa -import maya.mel as mel # noqa import pyblish.api from openpype.pipeline import publish @@ -22,12 +21,17 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Rig (FBX)" - families = ["rig.fbx"] + families = ["rig"] def process(self, instance): if not self.is_active(instance.data): return - # Define output path + if "rig.fbx" not in instance.data["families"]: + self.log.debug("No object inside skeletonMesh_set..Skipping..") + return + if not cmds.loadPlugin("fbxmaya", query=True): + cmds.loadPlugin("fbxmaya", quiet=True) + staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) @@ -46,6 +50,7 @@ class ExtractRigFBX(publish.Extractor, if "representations" not in instance.data: instance.data["representations"] = [] + self.log.debug("Families: {}".format(instance.data["families"])) representation = { 'name': 'fbx', @@ -54,5 +59,6 @@ class ExtractRigFBX(publish.Extractor, "stagingDir": staging_dir, } instance.data["representations"].append(representation) + self.log.debug("Representation: {}".format(representation)) self.log.debug("Extract FBX successful to: {0}".format(path)) From 8b3e2259be7cd7d308804fd964a337d20c73c961 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 12 Sep 2023 21:34:49 +0800 Subject: [PATCH 061/327] hound --- .../maya/plugins/publish/collect_fbx_animation.py | 4 +++- .../maya/plugins/publish/collect_rig_for_fbx.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index aef838223e..72501dc819 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -23,6 +23,8 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): instance.data["families"].append("animation.fbx") for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) - self.log.debug(f"Collected Animated Skeleton Set: {skeleton_content}") + self.log.debug( + "Collected animated " + f"skeleton data: {skeleton_content}") if skeleton_content: instance.data["animated_skeleton"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index fe8d5ca8ef..d571975438 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -24,7 +24,8 @@ class CollectRigFbx(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonmesh_set") ] if not skeleton_sets and skeleton_mesh_sets: - self.log.debug("no skeleton_set or skeleton_mesh set was found....") + self.log.debug( + "no skeleton_set or skeleton_mesh set was found....") return instance.data["skeleton_mesh"] = [] if skeleton_sets: @@ -32,11 +33,14 @@ class CollectRigFbx(pyblish.api.InstancePlugin): skeleton_content = cmds.sets(skeleton_set, query=True) if skeleton_content: instance.data["animated_rigs"] += skeleton_content - self.log.debug(f"Collected Skeleton Set: {skeleton_content}") + self.log.debug(f"Collected skeleton data: {skeleton_content}") if skeleton_mesh_sets: instance.data["families"].append("rig.fbx") for skeleton_mesh_set in skeleton_mesh_sets: - skeleton_mesh_content = cmds.sets(skeleton_mesh_set, query=True) + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) if skeleton_mesh_content: instance.data["skeleton_mesh"] += skeleton_mesh_content - self.log.debug(f"Collected SkeletonMesh Set: {skeleton_mesh_content}") + self.log.debug( + "Collected skeleton " + f"mesh Set: {skeleton_mesh_content}") From d949041ad9013e4aa4d02fb0f8e4cd6e540019ed Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 12 Sep 2023 15:10:04 +0100 Subject: [PATCH 062/327] Change behaviour for multilayer exr --- .../blender/plugins/create/create_render.py | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 84387ffb16..1c7f883836 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -177,34 +177,29 @@ class CreateRenderlayer(plugin.Creator): # Create a new output node output = tree.nodes.new("CompositorNodeOutputFile") - aov_file_products = [] - - if ext == "exr" and multilayer: - output.layer_slots.clear() - filepath = f"{name}{aov_sep}AOVs.####" - output.base_path = os.path.join(output_path, filepath) - - aov_file_products.append( - ("AOVs", os.path.join(output_path, filepath))) - else: - output.file_slots.clear() - output.base_path = output_path - image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format + # In case of a multilayer exr, we don't need to use the output node, + # because the blender render already outputs a multilayer exr. + if ext == "exr" and multilayer: + output.layer_slots.clear() + return [] + + output.file_slots.clear() + output.base_path = output_path + + aov_file_products = [] + # For each active render pass, we add a new socket to the output node # and link it for render_pass in passes: - if ext == "exr" and multilayer: - output.layer_slots.new(render_pass.name) - else: - filepath = f"{name}{aov_sep}{render_pass.name}.####" + filepath = f"{name}{aov_sep}{render_pass.name}.####" - output.file_slots.new(filepath) + output.file_slots.new(filepath) - aov_file_products.append( - (render_pass.name, os.path.join(output_path, filepath))) + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) node_input = output.inputs[-1] From e64984d510d84afb238df73296d8320cb3de2f3a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 13:34:44 +0800 Subject: [PATCH 063/327] update fbx param to support skeleton definition exports and add the optional validators to make sure it's always a top group hierarchy of the rig in the sets --- openpype/hosts/maya/api/fbx.py | 8 +-- .../plugins/publish/collect_rig_for_fbx.py | 3 +- .../plugins/publish/extract_fbx_animation.py | 1 + .../maya/plugins/publish/extract_rig_fbx.py | 3 +- .../validate_skeleton_top_group_hierarchy.py | 49 +++++++++++++++++++ .../schemas/schema_maya_publish.json | 4 ++ .../maya/server/settings/publishers.py | 9 ++++ 7 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 18b28f5154..306b7efe0b 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -40,7 +40,7 @@ class FBXExtractor: the option is not included and a warning is logged. """ - + #TODO: add skeletonDefinition return { "cameras": bool, "smoothingGroups": bool, @@ -65,7 +65,8 @@ class FBXExtractor: "inputConnections": bool, "upAxis": str, # x, y or z, "triangulate": bool, - "FileVersion": str + "FileVersion": str, + "skeletonDefinitions": bool } @property @@ -107,7 +108,8 @@ class FBXExtractor: "inputConnections": True, "upAxis": "y", "triangulate": False, - "fileVersion": "FBX202000" + "fileVersion": "FBX202000", + "skeletonDefinitions": False } def __init__(self, log=None): diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index d571975438..c9f3fea027 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -33,7 +33,8 @@ class CollectRigFbx(pyblish.api.InstancePlugin): skeleton_content = cmds.sets(skeleton_set, query=True) if skeleton_content: instance.data["animated_rigs"] += skeleton_content - self.log.debug(f"Collected skeleton data: {skeleton_content}") + self.log.debug("Collected skeleton" + f" data: {skeleton_content}") if skeleton_mesh_sets: instance.data["families"].append("rig.fbx") for skeleton_mesh_set in skeleton_mesh_sets: diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 2ac4734d21..b35cfbc271 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -42,6 +42,7 @@ class ExtractRigFBX(publish.Extractor, out_set = instance.data.get("animated_skeleton", []) instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True instance.data["animationOnly"] = True fbx_exporter.set_options_from_instance(instance) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 0df602fa29..122cfecf3c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -42,6 +42,7 @@ class ExtractRigFBX(publish.Extractor, out_set = instance.data.get("skeleton_mesh", []) instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True fbx_exporter.set_options_from_instance(instance) @@ -50,7 +51,6 @@ class ExtractRigFBX(publish.Extractor, if "representations" not in instance.data: instance.data["representations"] = [] - self.log.debug("Families: {}".format(instance.data["families"])) representation = { 'name': 'fbx', @@ -59,6 +59,5 @@ class ExtractRigFBX(publish.Extractor, "stagingDir": staging_dir, } instance.data["representations"].append(representation) - self.log.debug("Representation: {}".format(representation)) self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py new file mode 100644 index 0000000000..df434f132d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Plugin for validating naming conventions.""" +from maya import cmds + +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates top group hierarchy in the SETs + Make sure the object inside the SETs are always top + group of the hierarchy + + """ + order = ValidateContentsOrder + 0.05 + label = "Top Group Hierarchy" + families = ["rig"] + + def process(self, instance): + invalid = [] + skeleton_data = instance.data.get(("animated_rigs"), []) + skeletonMesh_data = instance.data(("skeleton_mesh"), []) + if skeleton_data: + invalid = self.get_top_hierarchy(skeleton_data) + if invalid: + raise PublishValidationError( + "The set includes the object which " + f"is not at the top hierarchy: {invalid}") + if skeletonMesh_data: + invalid = self.get_top_hierarchy(skeletonMesh_data) + if invalid: + raise PublishValidationError( + "The set includes the object which " + f"is not at the top hierarchy: {invalid}") + + def get_top_hierarchy(self, targets): + non_top_hierarchy_list = [] + for target in targets: + long_names = cmds.ls(target, long=True) + for name in long_names: + if len(name.split["|"]) > 2: + non_top_hierarchy_list.append(name) + return non_top_hierarchy_list diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index e8300282d7..e5fe367e77 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -824,6 +824,10 @@ { "key": "ValidateSkeletonRigOutputIds", "label": "Validate Skeleton Rig Output Ids" + }, + { + "key": "ValidateSkeletonTopGroupHierarchy", + "label": "Validate Skeleton Top Group Hierarchy" } ] }, diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index 6e3179b78e..0c733d9cbc 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -676,6 +676,10 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Skeleton Rig Output Ids" ) + ValidateSkeletonTopGroupHierarchy: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Top Group Hierarchy", + ) ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( default_factory=ValidateRigOutSetNodeIdsModel, title="Validate Rig Out Set Node Ids", @@ -1209,6 +1213,11 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateCameraAttributes": { "enabled": False, "optional": True, From c07e741b7a2a35982da423c017485b3f3687bedc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 13:35:55 +0800 Subject: [PATCH 064/327] hound --- openpype/hosts/maya/api/fbx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 306b7efe0b..9092aaec23 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -40,7 +40,7 @@ class FBXExtractor: the option is not included and a warning is logged. """ - #TODO: add skeletonDefinition + return { "cameras": bool, "smoothingGroups": bool, From 4bbb2e0ba35a902a618f97e1ee3d9cdde5de8135 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 13:40:43 +0800 Subject: [PATCH 065/327] add the validator into maya settings --- openpype/settings/defaults/project_settings/maya.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 2bc226c431..022b906c4f 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1170,6 +1170,11 @@ "optional": true, "active": true }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, From b72d241c2fc0e659fb879224a58883d1f5ba4db2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 14:00:04 +0800 Subject: [PATCH 066/327] bigRoy's comment --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 5 +---- openpype/hosts/maya/plugins/publish/extract_rig_fbx.py | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index b35cfbc271..cf6cb39628 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -21,14 +21,11 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" - families = ["animation"] + families = ["animation.fbx"] def process(self, instance): if not self.is_active(instance.data): return - if "animation.fbx" not in instance.data["families"]: - self.log.debug("No object inside skeleton_set..Skipping...") - return if not cmds.loadPlugin("fbxmaya", query=True): cmds.loadPlugin("fbxmaya", quiet=True) # Define output path diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 122cfecf3c..a81e9deaa1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -21,14 +21,11 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Rig (FBX)" - families = ["rig"] + families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): return - if "rig.fbx" not in instance.data["families"]: - self.log.debug("No object inside skeletonMesh_set..Skipping..") - return if not cmds.loadPlugin("fbxmaya", query=True): cmds.loadPlugin("fbxmaya", quiet=True) From 6d411cdbc952271276843041528951d0f228a7de Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 00:36:18 +0800 Subject: [PATCH 067/327] bug fix on Libor's comment --- .../hosts/maya/plugins/publish/collect_rig_for_fbx.py | 1 + .../hosts/maya/plugins/publish/extract_fbx_animation.py | 7 ++++--- openpype/hosts/maya/plugins/publish/extract_rig_fbx.py | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index c9f3fea027..215a2dd6f3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -28,6 +28,7 @@ class CollectRigFbx(pyblish.api.InstancePlugin): "no skeleton_set or skeleton_mesh set was found....") return instance.data["skeleton_mesh"] = [] + instance.data["animated_rigs"] = [] if skeleton_sets: for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index cf6cb39628..3c2b76c20d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -21,13 +21,14 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" - families = ["animation.fbx"] + families = ["animation"] def process(self, instance): if not self.is_active(instance.data): return - if not cmds.loadPlugin("fbxmaya", query=True): - cmds.loadPlugin("fbxmaya", quiet=True) + if "animation.fbx" not in instance.data["families"]: + self.log.debug("No object inside skeletonAnim_set..Skipping..") + return # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index a81e9deaa1..570ef2c267 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -21,14 +21,14 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Rig (FBX)" - families = ["rig.fbx"] + families = ["rig"] def process(self, instance): if not self.is_active(instance.data): return - if not cmds.loadPlugin("fbxmaya", query=True): - cmds.loadPlugin("fbxmaya", quiet=True) - + if "rig.fbx" not in instance.data["families"]: + self.log.debug("No object inside skeletonMesh_set..Skipping..") + return staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) From 5f67ffdeb035a249a5528bf82256c023b9a7ae90 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 00:39:04 +0800 Subject: [PATCH 068/327] ondrej's comment on the frame range --- openpype/hosts/maya/plugins/publish/collect_fbx_animation.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 72501dc819..8a4e7360a8 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -11,10 +11,6 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): families = ["animation"] def process(self, instance): - frame = cmds.currentTime(query=True) - instance.data["frameStart"] = frame - instance.data["frameEnd"] = frame - skeleton_sets = [ i for i in instance if i.lower().endswith("skeletonanim_set") From a2c72e683e2ac3420931a0721a9f3d12f843db96 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 17:10:46 +0800 Subject: [PATCH 069/327] add maya as hosts --- .../hosts/maya/plugins/publish/collect_fbx_animation.py | 1 + openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py | 1 + .../hosts/maya/plugins/publish/extract_fbx_animation.py | 6 ++---- openpype/hosts/maya/plugins/publish/extract_rig_fbx.py | 7 ++----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 8a4e7360a8..9749fb4770 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -8,6 +8,7 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.2 label = "Collect Fbx Animation" + hosts = ["maya"] families = ["animation"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py index 215a2dd6f3..65653b3369 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py @@ -8,6 +8,7 @@ class CollectRigFbx(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.2 label = "Collect rig for fbx" + hosts = ["maya"] families = ["rig"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 3c2b76c20d..1b4b63db87 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -21,14 +21,12 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" - families = ["animation"] + hosts = ["maya"] + families = ["animation.fbx"] def process(self, instance): if not self.is_active(instance.data): return - if "animation.fbx" not in instance.data["families"]: - self.log.debug("No object inside skeletonAnim_set..Skipping..") - return # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 570ef2c267..9eecde90e9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -21,18 +21,15 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder label = "Extract Rig (FBX)" - families = ["rig"] + hosts = ["maya"] + families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): return - if "rig.fbx" not in instance.data["families"]: - self.log.debug("No object inside skeletonMesh_set..Skipping..") - return staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) - # The export requires forward slashes because we need # to format it into a string in a mel expression fbx_exporter = fbx.FBXExtractor(log=self.log) From 6c8e162fa86f995168fb69428510705b95e0e9e7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 14 Sep 2023 19:27:36 +0200 Subject: [PATCH 070/327] :art: add settings --- server_addon/maya/server/settings/creators.py | 11 +++++++++++ server_addon/maya/server/version.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 11e2b8a36c..84e873589d 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -1,6 +1,7 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +from ayon_server.settings import task_types_enum class CreateLookModel(BaseSettingsModel): @@ -120,6 +121,16 @@ class CreateVrayProxyModel(BaseSettingsModel): default_factory=list, title="Default Products") +class CreateMultishotLayout(BasicCreatorModel): + shotParent: str = Field(title="Shot Parent Folder") + groupLoadedAssets: bool = Field(title="Group Loaded Assets") + task_type: list[str] = Field( + title="Task types", + enum_resolver=task_types_enum + ) + task_name: str = Field(title="Task name (regex)") + + class CreatorsModel(BaseSettingsModel): CreateLook: CreateLookModel = Field( default_factory=CreateLookModel, diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index e57ad00718..de699158fd 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.3" +__version__ = "0.1.4" From 96638726a90896673457dccc91f5bec5fd069ae9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 13:09:32 +0100 Subject: [PATCH 071/327] Produce reviews for the beauty render when publishing --- .../hosts/blender/plugins/create/create_render.py | 11 ++++++----- .../hosts/blender/plugins/publish/collect_render.py | 3 +++ .../deadline/plugins/publish/submit_publish_job.py | 1 + .../settings/defaults/project_settings/deadline.json | 3 +++ .../deadline/server/settings/publish_plugins.py | 6 ++++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 1c7f883836..abb04061af 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -62,7 +62,7 @@ class CreateRenderlayer(plugin.Creator): ["multilayer_exr"]) @staticmethod - def get_render_product(output_path, name): + def get_render_product(output_path, name, aov_sep): """ Generate the path to the render product. Blender interprets the `#` as the frame number, when it renders. @@ -74,7 +74,8 @@ class CreateRenderlayer(plugin.Creator): instance (pyblish.api.Instance): The instance to publish. ext (str): The image format to render. """ - render_product = f"{os.path.join(output_path, name)}.####" + filepath = os.path.join(output_path, name) + render_product = f"{filepath}{aov_sep}beauty.####" render_product = render_product.replace("\\", "/") return render_product @@ -233,17 +234,16 @@ class CreateRenderlayer(plugin.Creator): ext = self.get_image_format(settings) multilayer = self.get_multilayer(settings) + self.set_render_format(ext, multilayer) aov_list, custom_passes = self.set_render_passes(settings) output_path = os.path.join(file_path, render_folder, file_name) - render_product = self.get_render_product(output_path, name) + render_product = self.get_render_product(output_path, name, aov_sep) aov_file_product = self.set_node_tree( output_path, name, aov_sep, ext, multilayer) - # We set the render path, the format and the camera bpy.context.scene.render.filepath = render_product - self.set_render_format(ext, multilayer) render_settings = { "render_folder": render_folder, @@ -254,6 +254,7 @@ class CreateRenderlayer(plugin.Creator): "custom_passes": custom_passes, "render_product": render_product, "aov_file_product": aov_file_product, + "review": True, } self.imprint_render_settings(asset_group, render_settings) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index 557a4c9066..e0fc933241 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -82,6 +82,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): render_product = render_data.get("render_product") aov_file_product = render_data.get("aov_file_product") ext = render_data.get("image_format") + multilayer = render_data.get("multilayer_exr") frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] @@ -105,6 +106,8 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): "frameEndHandle": frame_handle_end, "fps": context.data["fps"], "byFrameStep": bpy.context.scene.frame_step, + "review": render_data.get("review", False), + "multipartExr": ext == "exr" and multilayer, "farm": True, "expectedFiles": [expected_files], # OCIO not currently implemented in Blender, but the following diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 609bfc3d3b..903b6e42e7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -107,6 +107,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "redshift_rop"] aov_filter = {"maya": [r".*([Bb]eauty).*"], + "blender": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE "harmony": [r".*"], # for everything from AE "celaction": [r".*"], diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 33ea533863..9e88f3b6f2 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -121,6 +121,9 @@ "maya": [ ".*([Bb]eauty).*" ], + "blender": [ + ".*([Bb]eauty).*" + ], "aftereffects": [ ".*" ], diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index a29caa7ba1..32a5d0e353 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -421,6 +421,12 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { ".*([Bb]eauty).*" ] }, + { + "name": "blender", + "value": [ + ".*([Bb]eauty).*" + ] + }, { "name": "aftereffects", "value": [ From 3c2c33bcea84ada5c9292e12fc53c51e74a73e5a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 17:06:29 +0100 Subject: [PATCH 072/327] Reorganized code and added validator to check the output folder --- openpype/hosts/blender/api/__init__.py | 3 + openpype/hosts/blender/api/render_lib.py | 240 +++++++++++++++++ .../blender/plugins/create/create_render.py | 248 +----------------- .../publish/validate_deadline_publish.py | 47 ++++ 4 files changed, 293 insertions(+), 245 deletions(-) create mode 100644 openpype/hosts/blender/api/render_lib.py create mode 100644 openpype/hosts/blender/plugins/publish/validate_deadline_publish.py diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index 75a11affde..e15f1193a5 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -38,6 +38,8 @@ from .lib import ( from .capture import capture +from .render_lib import prepare_rendering + __all__ = [ "install", @@ -66,4 +68,5 @@ __all__ = [ "get_selection", "capture", # "unique_name", + "prepare_rendering", ] diff --git a/openpype/hosts/blender/api/render_lib.py b/openpype/hosts/blender/api/render_lib.py new file mode 100644 index 0000000000..994de43503 --- /dev/null +++ b/openpype/hosts/blender/api/render_lib.py @@ -0,0 +1,240 @@ +import os + +import bpy + +from openpype.settings import get_project_settings +from openpype.pipeline import get_current_project_name + + +def get_default_render_folder(settings): + """Get default render folder from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["default_render_image_folder"]) + +def get_aov_separator(settings): + """Get aov separator from blender settings.""" + + aov_sep = (settings["blender"] + ["RenderSettings"] + ["aov_separator"]) + + if aov_sep == "dash": + return "-" + elif aov_sep == "underscore": + return "_" + elif aov_sep == "dot": + return "." + else: + raise ValueError(f"Invalid aov separator: {aov_sep}") + +def get_image_format(settings): + """Get image format from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["image_format"]) + +def get_multilayer(settings): + """Get multilayer from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["multilayer_exr"]) + +def get_render_product(output_path, name, aov_sep): + """ + Generate the path to the render product. Blender interprets the `#` + as the frame number, when it renders. + + Args: + file_path (str): The path to the blender scene. + render_folder (str): The render folder set in settings. + file_name (str): The name of the blender scene. + instance (pyblish.api.Instance): The instance to publish. + ext (str): The image format to render. + """ + filepath = os.path.join(output_path, name) + render_product = f"{filepath}{aov_sep}beauty.####" + render_product = render_product.replace("\\", "/") + + return render_product + +def set_render_format(ext, multilayer): + # Set Blender to save the file with the right extension + bpy.context.scene.render.use_file_extension = True + + image_settings = bpy.context.scene.render.image_settings + + if ext == "exr": + image_settings.file_format = ( + "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") + elif ext == "bmp": + image_settings.file_format = "BMP" + elif ext == "rgb": + image_settings.file_format = "IRIS" + elif ext == "png": + image_settings.file_format = "PNG" + elif ext == "jpeg": + image_settings.file_format = "JPEG" + elif ext == "jp2": + image_settings.file_format = "JPEG2000" + elif ext == "tga": + image_settings.file_format = "TARGA" + elif ext == "tif": + image_settings.file_format = "TIFF" + +def set_render_passes(settings): + aov_list = (settings["blender"] + ["RenderSettings"] + ["aov_list"]) + + custom_passes = (settings["blender"] + ["RenderSettings"] + ["custom_passes"]) + + vl = bpy.context.view_layer + + vl.use_pass_combined = "combined" in aov_list + vl.use_pass_z = "z" in aov_list + vl.use_pass_mist = "mist" in aov_list + vl.use_pass_normal = "normal" in aov_list + vl.use_pass_diffuse_direct = "diffuse_light" in aov_list + vl.use_pass_diffuse_color = "diffuse_color" in aov_list + vl.use_pass_glossy_direct = "specular_light" in aov_list + vl.use_pass_glossy_color = "specular_color" in aov_list + vl.eevee.use_pass_volume_direct = "volume_light" in aov_list + vl.use_pass_emit = "emission" in aov_list + vl.use_pass_environment = "environment" in aov_list + vl.use_pass_shadow = "shadow" in aov_list + vl.use_pass_ambient_occlusion = "ao" in aov_list + + aovs_names = [aov.name for aov in vl.aovs] + for cp in custom_passes: + cp_name = cp[0] + if cp_name not in aovs_names: + aov = vl.aovs.add() + aov.name = cp_name + else: + aov = vl.aovs[cp_name] + aov.type = cp[1].get("type", "VALUE") + + return aov_list, custom_passes + +def set_node_tree(output_path, name, aov_sep, ext, multilayer): + # Set the scene to use the compositor node tree to render + bpy.context.scene.use_nodes = True + + tree = bpy.context.scene.node_tree + + # Get the Render Layers node + rl_node = None + for node in tree.nodes: + if node.bl_idname == "CompositorNodeRLayers": + rl_node = node + break + + # If there's not a Render Layers node, we create it + if not rl_node: + rl_node = tree.nodes.new("CompositorNodeRLayers") + + # Get the enabled output sockets, that are the active passes for the + # render. + # We also exclude some layers. + exclude_sockets = ["Image", "Alpha"] + passes = [ + socket + for socket in rl_node.outputs + if socket.enabled and socket.name not in exclude_sockets + ] + + # Remove all output nodes + for node in tree.nodes: + if node.bl_idname == "CompositorNodeOutputFile": + tree.nodes.remove(node) + + # Create a new output node + output = tree.nodes.new("CompositorNodeOutputFile") + + image_settings = bpy.context.scene.render.image_settings + output.format.file_format = image_settings.file_format + + # In case of a multilayer exr, we don't need to use the output node, + # because the blender render already outputs a multilayer exr. + if ext == "exr" and multilayer: + output.layer_slots.clear() + return [] + + output.file_slots.clear() + output.base_path = output_path + + aov_file_products = [] + + # For each active render pass, we add a new socket to the output node + # and link it + for render_pass in passes: + filepath = f"{name}{aov_sep}{render_pass.name}.####" + + output.file_slots.new(filepath) + + aov_file_products.append( + (render_pass.name, os.path.join(output_path, filepath))) + + node_input = output.inputs[-1] + + tree.links.new(render_pass, node_input) + + return aov_file_products + +def imprint_render_settings(node, data): + RENDER_DATA = "render_data" + if not node.get(RENDER_DATA): + node[RENDER_DATA] = {} + for key, value in data.items(): + if value is None: + continue + node[RENDER_DATA][key] = value + +def prepare_rendering(asset_group): + name = asset_group.name + + filepath = bpy.data.filepath + assert filepath, "Workfile not saved. Please save the file first." + + file_path = os.path.dirname(filepath) + file_name = os.path.basename(filepath) + file_name, _ = os.path.splitext(file_name) + + project = get_current_project_name() + settings = get_project_settings(project) + + render_folder = get_default_render_folder(settings) + aov_sep = get_aov_separator(settings) + ext = get_image_format(settings) + multilayer = get_multilayer(settings) + + set_render_format(ext, multilayer) + aov_list, custom_passes = set_render_passes(settings) + + output_path = os.path.join(file_path, render_folder, file_name) + + render_product = get_render_product(output_path, name, aov_sep) + aov_file_product = set_node_tree( + output_path, name, aov_sep, ext, multilayer) + + bpy.context.scene.render.filepath = render_product + + render_settings = { + "render_folder": render_folder, + "aov_separator": aov_sep, + "image_format": ext, + "multilayer_exr": multilayer, + "aov_list": aov_list, + "custom_passes": custom_passes, + "render_product": render_product, + "aov_file_product": aov_file_product, + "review": True, + } + + imprint_render_settings(asset_group, render_settings) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index abb04061af..7a91726a5f 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -3,12 +3,9 @@ import os import bpy -from openpype.settings import get_project_settings -from openpype.pipeline import ( - get_current_project_name, - get_current_task_name, -) +from openpype.pipeline import get_current_task_name from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.render_lib import prepare_rendering from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,245 +17,6 @@ class CreateRenderlayer(plugin.Creator): family = "renderlayer" icon = "eye" - @staticmethod - def get_default_render_folder(settings): - """Get default render folder from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["default_render_image_folder"]) - - @staticmethod - def get_aov_separator(settings): - """Get aov separator from blender settings.""" - - aov_sep = (settings["blender"] - ["RenderSettings"] - ["aov_separator"]) - - if aov_sep == "dash": - return "-" - elif aov_sep == "underscore": - return "_" - elif aov_sep == "dot": - return "." - else: - raise ValueError(f"Invalid aov separator: {aov_sep}") - - @staticmethod - def get_image_format(settings): - """Get image format from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["image_format"]) - - @staticmethod - def get_multilayer(settings): - """Get multilayer from blender settings.""" - - return (settings["blender"] - ["RenderSettings"] - ["multilayer_exr"]) - - @staticmethod - def get_render_product(output_path, name, aov_sep): - """ - Generate the path to the render product. Blender interprets the `#` - as the frame number, when it renders. - - Args: - file_path (str): The path to the blender scene. - render_folder (str): The render folder set in settings. - file_name (str): The name of the blender scene. - instance (pyblish.api.Instance): The instance to publish. - ext (str): The image format to render. - """ - filepath = os.path.join(output_path, name) - render_product = f"{filepath}{aov_sep}beauty.####" - render_product = render_product.replace("\\", "/") - - return render_product - - @staticmethod - def set_render_format(ext, multilayer): - # Set Blender to save the file with the right extension - bpy.context.scene.render.use_file_extension = True - - image_settings = bpy.context.scene.render.image_settings - - if ext == "exr": - image_settings.file_format = ( - "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") - elif ext == "bmp": - image_settings.file_format = "BMP" - elif ext == "rgb": - image_settings.file_format = "IRIS" - elif ext == "png": - image_settings.file_format = "PNG" - elif ext == "jpeg": - image_settings.file_format = "JPEG" - elif ext == "jp2": - image_settings.file_format = "JPEG2000" - elif ext == "tga": - image_settings.file_format = "TARGA" - elif ext == "tif": - image_settings.file_format = "TIFF" - - @staticmethod - def set_render_passes(settings): - aov_list = (settings["blender"] - ["RenderSettings"] - ["aov_list"]) - - custom_passes = (settings["blender"] - ["RenderSettings"] - ["custom_passes"]) - - vl = bpy.context.view_layer - - vl.use_pass_combined = "combined" in aov_list - vl.use_pass_z = "z" in aov_list - vl.use_pass_mist = "mist" in aov_list - vl.use_pass_normal = "normal" in aov_list - vl.use_pass_diffuse_direct = "diffuse_light" in aov_list - vl.use_pass_diffuse_color = "diffuse_color" in aov_list - vl.use_pass_glossy_direct = "specular_light" in aov_list - vl.use_pass_glossy_color = "specular_color" in aov_list - vl.eevee.use_pass_volume_direct = "volume_light" in aov_list - vl.use_pass_emit = "emission" in aov_list - vl.use_pass_environment = "environment" in aov_list - vl.use_pass_shadow = "shadow" in aov_list - vl.use_pass_ambient_occlusion = "ao" in aov_list - - aovs_names = [aov.name for aov in vl.aovs] - for cp in custom_passes: - cp_name = cp[0] - if cp_name not in aovs_names: - aov = vl.aovs.add() - aov.name = cp_name - else: - aov = vl.aovs[cp_name] - aov.type = cp[1].get("type", "VALUE") - - return aov_list, custom_passes - - def set_node_tree(self, output_path, name, aov_sep, ext, multilayer): - # Set the scene to use the compositor node tree to render - bpy.context.scene.use_nodes = True - - tree = bpy.context.scene.node_tree - - # Get the Render Layers node - rl_node = None - for node in tree.nodes: - if node.bl_idname == "CompositorNodeRLayers": - rl_node = node - break - - # If there's not a Render Layers node, we create it - if not rl_node: - rl_node = tree.nodes.new("CompositorNodeRLayers") - - # Get the enabled output sockets, that are the active passes for the - # render. - # We also exclude some layers. - exclude_sockets = ["Image", "Alpha"] - passes = [ - socket - for socket in rl_node.outputs - if socket.enabled and socket.name not in exclude_sockets - ] - - # Remove all output nodes - for node in tree.nodes: - if node.bl_idname == "CompositorNodeOutputFile": - tree.nodes.remove(node) - - # Create a new output node - output = tree.nodes.new("CompositorNodeOutputFile") - - image_settings = bpy.context.scene.render.image_settings - output.format.file_format = image_settings.file_format - - # In case of a multilayer exr, we don't need to use the output node, - # because the blender render already outputs a multilayer exr. - if ext == "exr" and multilayer: - output.layer_slots.clear() - return [] - - output.file_slots.clear() - output.base_path = output_path - - aov_file_products = [] - - # For each active render pass, we add a new socket to the output node - # and link it - for render_pass in passes: - filepath = f"{name}{aov_sep}{render_pass.name}.####" - - output.file_slots.new(filepath) - - aov_file_products.append( - (render_pass.name, os.path.join(output_path, filepath))) - - node_input = output.inputs[-1] - - tree.links.new(render_pass, node_input) - - return aov_file_products - - @staticmethod - def imprint_render_settings(node, data): - RENDER_DATA = "render_data" - if not node.get(RENDER_DATA): - node[RENDER_DATA] = {} - for key, value in data.items(): - if value is None: - continue - node[RENDER_DATA][key] = value - - def prepare_rendering(self, asset_group, name): - filepath = bpy.data.filepath - assert filepath, "Workfile not saved. Please save the file first." - - file_path = os.path.dirname(filepath) - file_name = os.path.basename(filepath) - file_name, _ = os.path.splitext(file_name) - - project = get_current_project_name() - settings = get_project_settings(project) - - render_folder = self.get_default_render_folder(settings) - aov_sep = self.get_aov_separator(settings) - ext = self.get_image_format(settings) - multilayer = self.get_multilayer(settings) - - self.set_render_format(ext, multilayer) - aov_list, custom_passes = self.set_render_passes(settings) - - output_path = os.path.join(file_path, render_folder, file_name) - - render_product = self.get_render_product(output_path, name, aov_sep) - aov_file_product = self.set_node_tree( - output_path, name, aov_sep, ext, multilayer) - - bpy.context.scene.render.filepath = render_product - - render_settings = { - "render_folder": render_folder, - "aov_separator": aov_sep, - "image_format": ext, - "multilayer_exr": multilayer, - "aov_list": aov_list, - "custom_passes": custom_passes, - "render_product": render_product, - "aov_file_product": aov_file_product, - "review": True, - } - - self.imprint_render_settings(asset_group, render_settings) - def process(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) @@ -277,7 +35,7 @@ class CreateRenderlayer(plugin.Creator): self.data['task'] = get_current_task_name() lib.imprint(asset_group, self.data) - self.prepare_rendering(asset_group, name) + prepare_rendering(asset_group) except Exception: # Remove the instance if there was an error bpy.data.collections.remove(asset_group) diff --git a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py new file mode 100644 index 0000000000..54a4442bdb --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py @@ -0,0 +1,47 @@ +import os + +import bpy + +import pyblish.api +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.hosts.blender.api.render_lib import prepare_rendering + + +class ValidateDeadlinePublish(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Render File Directory is + not the same in every submission + """ + + order = ValidateContentsOrder + families = ["renderlayer"] + hosts = ["blender"] + label = "Validate Render Output for Deadline" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + filepath = bpy.data.filepath + file = os.path.basename(filepath) + filename, ext = os.path.splitext(file) + if filename not in bpy.context.scene.render.filepath: + raise PublishValidationError( + "Render output folder " + "doesn't match the max scene name! " + "Use Repair action to " + "fix the folder file path.." + ) + + @classmethod + def repair(cls, instance): + container = bpy.data.collections[str(instance)] + prepare_rendering(container) + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + cls.log.debug("Reset the render output folder...") From c315ee8f65db1a6a973d6480070c5c098c33327f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 17:20:26 +0100 Subject: [PATCH 073/327] Hound fixes --- openpype/hosts/blender/api/render_lib.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/api/render_lib.py b/openpype/hosts/blender/api/render_lib.py index 994de43503..43560ee6d5 100644 --- a/openpype/hosts/blender/api/render_lib.py +++ b/openpype/hosts/blender/api/render_lib.py @@ -13,12 +13,13 @@ def get_default_render_folder(settings): ["RenderSettings"] ["default_render_image_folder"]) + def get_aov_separator(settings): """Get aov separator from blender settings.""" aov_sep = (settings["blender"] - ["RenderSettings"] - ["aov_separator"]) + ["RenderSettings"] + ["aov_separator"]) if aov_sep == "dash": return "-" @@ -29,6 +30,7 @@ def get_aov_separator(settings): else: raise ValueError(f"Invalid aov separator: {aov_sep}") + def get_image_format(settings): """Get image format from blender settings.""" @@ -36,6 +38,7 @@ def get_image_format(settings): ["RenderSettings"] ["image_format"]) + def get_multilayer(settings): """Get multilayer from blender settings.""" @@ -43,6 +46,7 @@ def get_multilayer(settings): ["RenderSettings"] ["multilayer_exr"]) + def get_render_product(output_path, name, aov_sep): """ Generate the path to the render product. Blender interprets the `#` @@ -61,6 +65,7 @@ def get_render_product(output_path, name, aov_sep): return render_product + def set_render_format(ext, multilayer): # Set Blender to save the file with the right extension bpy.context.scene.render.use_file_extension = True @@ -85,14 +90,15 @@ def set_render_format(ext, multilayer): elif ext == "tif": image_settings.file_format = "TIFF" + def set_render_passes(settings): aov_list = (settings["blender"] ["RenderSettings"] ["aov_list"]) custom_passes = (settings["blender"] - ["RenderSettings"] - ["custom_passes"]) + ["RenderSettings"] + ["custom_passes"]) vl = bpy.context.view_layer @@ -122,6 +128,7 @@ def set_render_passes(settings): return aov_list, custom_passes + def set_node_tree(output_path, name, aov_sep, ext, multilayer): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True @@ -187,6 +194,7 @@ def set_node_tree(output_path, name, aov_sep, ext, multilayer): return aov_file_products + def imprint_render_settings(node, data): RENDER_DATA = "render_data" if not node.get(RENDER_DATA): @@ -196,6 +204,7 @@ def imprint_render_settings(node, data): continue node[RENDER_DATA][key] = value + def prepare_rendering(asset_group): name = asset_group.name From 11921a9de995794cbc950ce183b5d3e837d78d17 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 17:20:43 +0100 Subject: [PATCH 074/327] Added settings for new validator --- .../defaults/project_settings/blender.json | 5 ++ .../schemas/schema_blender_publish.json | 84 +++++++++++++------ .../server/settings/publish_plugins.py | 10 +++ 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 9cbbb49593..f3eb31174f 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -46,6 +46,11 @@ "optional": false, "active": true }, + "ValidateDeadlinePublish": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateMeshHasUvs": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 05e7f13e70..7f1a8a915b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -51,30 +51,6 @@ } ] }, - { - "type": "dict", - "collapsible": true, - "key": "ValidateRenderCameraIsSet", - "label": "Validate Render Camera Is Set", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "boolean", - "key": "optional", - "label": "Optional" - }, - { - "type": "boolean", - "key": "active", - "label": "Active" - } - ] - }, { "type": "collapsible-wrap", "label": "Model", @@ -103,6 +79,66 @@ } ] }, + { + "type": "collapsible-wrap", + "label": "Render", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderCameraIsSet", + "label": "Validate Render Camera Is Set", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateDeadlinePublish", + "label": "Validate Render Output for Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + } + ] + } + ] + }, { "type": "splitter" }, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 575bfe9f39..5e047b7013 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -73,6 +73,11 @@ class PublishPuginsModel(BaseSettingsModel): title="Validate Render Camera Is Set", section="Validators" ) + ValidateDeadlinePublish: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Output for Deadline", + section="Validators" + ) ValidateMeshHasUvs: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Mesh Has Uvs" @@ -149,6 +154,11 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateDeadlinePublish": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateMeshHasUvs": { "enabled": True, "optional": True, From 2acbf241ad12b32affa6772e87728c31e0b3135c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 18:05:23 +0100 Subject: [PATCH 075/327] Changed family to "render" --- openpype/hosts/blender/plugins/create/create_render.py | 4 +--- openpype/hosts/blender/plugins/publish/collect_render.py | 3 ++- .../blender/plugins/publish/increment_workfile_version.py | 2 +- .../blender/plugins/publish/validate_deadline_publish.py | 2 +- .../blender/plugins/publish/validate_render_camera_is_set.py | 2 +- .../deadline/plugins/publish/submit_blender_deadline.py | 2 +- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 7a91726a5f..f938a21808 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -1,6 +1,4 @@ """Create render.""" -import os - import bpy from openpype.pipeline import get_current_task_name @@ -14,7 +12,7 @@ class CreateRenderlayer(plugin.Creator): name = "renderingMain" label = "Render" - family = "renderlayer" + family = "render" icon = "eye" def process(self): diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py index e0fc933241..92e2473a95 100644 --- a/openpype/hosts/blender/plugins/publish/collect_render.py +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -15,7 +15,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.01 hosts = ["blender"] - families = ["renderlayer"] + families = ["render"] label = "Collect Render Layers" sync_workfile_version = False @@ -100,6 +100,7 @@ class CollectBlenderRender(pyblish.api.InstancePlugin): expected_files = expected_beauty | expected_aovs instance.data.update({ + "family": "render.farm", "frameStart": frame_start, "frameEnd": frame_end, "frameStartHandle": frame_handle_start, diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 5f49ad7185..3d176f9c30 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -10,7 +10,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): optional = True hosts = ["blender"] families = ["animation", "model", "rig", "action", "layout", "blendScene", - "renderlayer"] + "render"] def process(self, context): diff --git a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py index 54a4442bdb..f89a7d3d58 100644 --- a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py +++ b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py @@ -19,7 +19,7 @@ class ValidateDeadlinePublish(pyblish.api.InstancePlugin, """ order = ValidateContentsOrder - families = ["renderlayer"] + families = ["render.farm"] hosts = ["blender"] label = "Validate Render Output for Deadline" optional = True diff --git a/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py index 5a06c1ff0a..ba3a796f35 100644 --- a/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py +++ b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py @@ -8,7 +8,7 @@ class ValidateRenderCameraIsSet(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder hosts = ["blender"] - families = ["renderlayer"] + families = ["render"] label = "Validate Render Camera Is Set" optional = False diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index ad456c0d13..307fc8b5a2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -27,7 +27,7 @@ class BlenderPluginInfo(): class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): label = "Submit Render to Deadline" hosts = ["blender"] - families = ["renderlayer"] + families = ["render.farm"] use_published = True priority = 50 From c78d496ec948e1108ee23f2babf17878ddaa26fa Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 18 Sep 2023 12:17:22 +0800 Subject: [PATCH 076/327] including skeleton sets into animation sets during loading rig & fixing the rig fbx extractor not being displayed --- openpype/hosts/maya/api/lib.py | 16 ++++++++++++++-- .../plugins/publish/collect_fbx_animation.py | 5 +++-- ..._rig_for_fbx.py => collect_skeleton_mesh.py} | 17 +++++------------ .../plugins/publish/extract_fbx_animation.py | 2 +- .../maya/plugins/publish/extract_rig_fbx.py | 14 ++++++++------ 5 files changed, 31 insertions(+), 23 deletions(-) rename openpype/hosts/maya/plugins/publish/{collect_rig_for_fbx.py => collect_skeleton_mesh.py} (67%) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 40b3419e73..2ff4ff42de 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4109,6 +4109,14 @@ def create_rig_animation_instance( assert output, "No out_SET in rig, this is a bug." assert controls, "No controls_SET in rig, this is a bug." + anim_skeleton = next((node for node in nodes if + node.endswith("skeletonAnim_SET")), None) + if not anim_skeleton: + log.debug("No skeletonAnim_SET in rig") + skeleton_mesh = next((node for node in nodes if + node.endswith("skeletonMesh_SET")), None) + if not skeleton_mesh: + log.debug("No skeletonMesh_SET in rig") # Find the roots amongst the loaded nodes roots = ( cmds.ls(nodes, assemblies=True, long=True) or @@ -4142,10 +4150,14 @@ def create_rig_animation_instance( host = registered_host() create_context = CreateContext(host) - # Create the animation instance + rig_sets = [output, controls] + if anim_skeleton: + rig_sets.append(anim_skeleton) + if skeleton_mesh: + rig_sets.append(skeleton_mesh) with maintained_selection(): - cmds.select([output, controls] + roots, noExpand=True) + cmds.select(rig_sets + roots, noExpand=True) create_context.create( creator_identifier=creator_identifier, variant=namespace, diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 9749fb4770..75e36e78ce 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -4,7 +4,7 @@ import pyblish.api class CollectFbxAnimation(pyblish.api.InstancePlugin): - """Collect Unreal Skeletal Mesh.""" + """Collect Animated Rig Data for FBX Extractor.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Fbx Animation" @@ -17,7 +17,8 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin): if i.lower().endswith("skeletonanim_set") ] if skeleton_sets: - instance.data["families"].append("animation.fbx") + instance.data["families"] += ["animation.fbx"] + instance.data["animated_skeleton"] = [] for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) self.log.debug( diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py similarity index 67% rename from openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py rename to openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index 65653b3369..ccf65441a2 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_for_fbx.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -3,11 +3,11 @@ from maya import cmds # noqa import pyblish.api -class CollectRigFbx(pyblish.api.InstancePlugin): - """Collect Unreal Skeletal Mesh.""" +class CollectSkeletonMesh(pyblish.api.InstancePlugin): + """Collect Static Rig Data for FBX Extractor.""" order = pyblish.api.CollectorOrder + 0.2 - label = "Collect rig for fbx" + label = "Collect Skeleton Mesh" hosts = ["maya"] families = ["rig"] @@ -29,16 +29,9 @@ class CollectRigFbx(pyblish.api.InstancePlugin): "no skeleton_set or skeleton_mesh set was found....") return instance.data["skeleton_mesh"] = [] - instance.data["animated_rigs"] = [] - if skeleton_sets: - for skeleton_set in skeleton_sets: - skeleton_content = cmds.sets(skeleton_set, query=True) - if skeleton_content: - instance.data["animated_rigs"] += skeleton_content - self.log.debug("Collected skeleton" - f" data: {skeleton_content}") + if skeleton_mesh_sets: - instance.data["families"].append("rig.fbx") + instance.data["families"] += ["rig.fbx"] for skeleton_mesh_set in skeleton_mesh_sets: skeleton_mesh_content = cmds.sets( skeleton_mesh_set, query=True) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 1b4b63db87..ef8b22d452 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,7 +44,7 @@ class ExtractRigFBX(publish.Extractor, fbx_exporter.set_options_from_instance(instance) # Export - fbx_exporter.export(out_set, path) + fbx_exporter.export(out_set, path.replace("\\", "/")) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py index 9eecde90e9..c9fe53f0be 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py @@ -9,8 +9,8 @@ from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.maya.api import fbx -class ExtractRigFBX(publish.Extractor, - OptionalPyblishPluginMixin): +class ExtractSkeletonMesh(publish.Extractor, + OptionalPyblishPluginMixin): """Extract Rig in FBX format from Maya. This extracts the rig in fbx with the constraints @@ -20,16 +20,18 @@ class ExtractRigFBX(publish.Extractor, """ order = pyblish.api.ExtractorOrder - label = "Extract Rig (FBX)" + label = "Extract Skeleton Mesh" hosts = ["maya"] families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): return + # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) + # The export requires forward slashes because we need # to format it into a string in a mel expression fbx_exporter = fbx.FBXExtractor(log=self.log) @@ -41,7 +43,7 @@ class ExtractRigFBX(publish.Extractor, fbx_exporter.set_options_from_instance(instance) # Export - fbx_exporter.export(out_set, path) + fbx_exporter.export(out_set, path.replace("\\", "/")) if "representations" not in instance.data: instance.data["representations"] = [] @@ -50,8 +52,8 @@ class ExtractRigFBX(publish.Extractor, 'name': 'fbx', 'ext': 'fbx', 'files': filename, - "stagingDir": staging_dir, + "stagingDir": staging_dir } instance.data["representations"].append(representation) - self.log.debug("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract animated FBX successful to: {0}".format(path)) From ccf4b7f54781d81d9d6d331f63e3a5a8ab0e0d6f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 18 Sep 2023 10:52:38 +0100 Subject: [PATCH 077/327] Fix error description Co-authored-by: Roy Nieterau --- .../hosts/blender/plugins/publish/validate_deadline_publish.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py index f89a7d3d58..14220b5c9c 100644 --- a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py +++ b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py @@ -34,7 +34,7 @@ class ValidateDeadlinePublish(pyblish.api.InstancePlugin, if filename not in bpy.context.scene.render.filepath: raise PublishValidationError( "Render output folder " - "doesn't match the max scene name! " + "doesn't match the blender scene name! " "Use Repair action to " "fix the folder file path.." ) From 4737ca8d5962b3d27d93f6799f58884e2c0a47ae Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 18 Sep 2023 11:08:21 +0100 Subject: [PATCH 078/327] Added note on the Multilayer EXR setting --- .../schemas/projects_schema/schema_project_blender.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index a283a2ff5c..4c9405fcd3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -99,6 +99,10 @@ "type": "boolean", "label": "Multilayer (EXR)" }, + { + "type": "label", + "label": "Note: Multilayer EXR is only used when output format type set to EXR." + }, { "key": "aov_list", "label": "AOVs to create", From b5d789e09bdc576c38197d590abcb23999fcb635 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 18 Sep 2023 20:36:00 +0800 Subject: [PATCH 079/327] remove animationOnly parameter as it would convert the joint to transform data --- openpype/hosts/maya/api/fbx.py | 7 ++++--- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 9092aaec23..c06ba12719 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -66,7 +66,8 @@ class FBXExtractor: "upAxis": str, # x, y or z, "triangulate": bool, "FileVersion": str, - "skeletonDefinitions": bool + "skeletonDefinitions": bool, + "referencedAssetsContent": bool } @property @@ -97,7 +98,6 @@ class FBXExtractor: "bakeComplexEnd": end_frame, "bakeComplexStep": 1, "bakeResampleAnimation": True, - "animationOnly": False, "useSceneName": False, "quaternion": "euler", "shapes": True, @@ -109,7 +109,8 @@ class FBXExtractor: "upAxis": "y", "triangulate": False, "fileVersion": "FBX202000", - "skeletonDefinitions": False + "skeletonDefinitions": False, + "referencedAssetsContent": False } def __init__(self, log=None): diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index ef8b22d452..8e96d46344 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -2,6 +2,7 @@ import os from maya import cmds # noqa +import maya.mel as mel import pyblish.api from openpype.pipeline import publish @@ -36,14 +37,12 @@ class ExtractRigFBX(publish.Extractor, # to format it into a string in a mel expression fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("animated_skeleton", []) - + # Export instance.data["constraints"] = True instance.data["skeletonDefinitions"] = True - instance.data["animationOnly"] = True + instance.data["referencedAssetsContent"] = True fbx_exporter.set_options_from_instance(instance) - - # Export fbx_exporter.export(out_set, path.replace("\\", "/")) if "representations" not in instance.data: From 5429616e1ee894582afdc3422a7b53b079da9495 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 18 Sep 2023 21:50:13 +0800 Subject: [PATCH 080/327] add fbx as representation to the loader and hound fix --- openpype/hosts/maya/api/fbx.py | 1 - openpype/hosts/maya/api/lib.py | 8 ++++---- openpype/hosts/maya/plugins/load/_load_animation.py | 2 +- openpype/hosts/maya/plugins/load/load_reference.py | 2 +- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 4 +--- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index c06ba12719..5bd375362b 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -54,7 +54,6 @@ class FBXExtractor: "bakeComplexEnd": int, "bakeComplexStep": int, "bakeResampleAnimation": bool, - "animationOnly": bool, "useSceneName": bool, "quaternion": str, # "euler" "shapes": bool, diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 2ff4ff42de..2769f05c35 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4100,14 +4100,14 @@ def create_rig_animation_instance( """ if options is None: options = {} - + name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if node.endswith("controls_SET")), None) - - assert output, "No out_SET in rig, this is a bug." - assert controls, "No controls_SET in rig, this is a bug." + if name != "fbx": + assert output, "No out_SET in rig, this is a bug." + assert controls, "No controls_SET in rig, this is a bug." anim_skeleton = next((node for node in nodes if node.endswith("skeletonAnim_SET")), None) diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 981b9ef434..6d67383909 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -7,7 +7,7 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): families = ["animation", "camera", "pointcache"] - representations = ["abc"] + representations = ["abc", "fbx"] label = "Reference animation" order = -10 diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 61f337f501..c9c3fb9786 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -117,7 +117,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): family = context["representation"]["context"]["family"] except ValueError: family = "model" - + print(f"family:{family}") project_name = context["project"]["name"] # True by default to keep legacy behaviours attach_to_root = options.get("attach_to_root", True) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 8e96d46344..142d815a29 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -2,7 +2,6 @@ import os from maya import cmds # noqa -import maya.mel as mel import pyblish.api from openpype.pipeline import publish @@ -52,8 +51,7 @@ class ExtractRigFBX(publish.Extractor, 'name': 'fbx', 'ext': 'fbx', 'files': filename, - "stagingDir": staging_dir, - "outputName": "fbxanim" + "stagingDir": staging_dir } instance.data["representations"].append(representation) From 7252acceb89f7b175bf2b22235bb8ab66d0dbe03 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 18 Sep 2023 21:52:17 +0800 Subject: [PATCH 081/327] hound --- openpype/hosts/maya/api/lib.py | 2 +- openpype/hosts/maya/plugins/load/load_reference.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 2769f05c35..d889fe4b8c 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4100,7 +4100,7 @@ def create_rig_animation_instance( """ if options is None: options = {} - name = context["representation"]["name"] + name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index c9c3fb9786..61f337f501 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -117,7 +117,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): family = context["representation"]["context"]["family"] except ValueError: family = "model" - print(f"family:{family}") + project_name = context["project"]["name"] # True by default to keep legacy behaviours attach_to_root = options.get("attach_to_root", True) From b7995a41a71fc8d0e9593f7c9940a8e7f06de9dc Mon Sep 17 00:00:00 2001 From: Kayla Date: Wed, 20 Sep 2023 21:26:02 +0800 Subject: [PATCH 082/327] enable the skeleton rig content validator and make the fbx animation collector optional and use the asset as both asset_name and asset_type data for custom subset in the loader --- openpype/hosts/maya/api/lib.py | 6 ++- .../plugins/publish/collect_fbx_animation.py | 9 ++++- .../plugins/publish/collect_skeleton_mesh.py | 10 +++++ .../plugins/publish/extract_fbx_animation.py | 6 +-- ...ct_rig_fbx.py => extract_skeleton_mesh.py} | 0 .../publish/validate_skeleton_rig_content.py | 40 +++++++++---------- .../defaults/project_settings/maya.json | 5 ++- .../schemas/schema_maya_publish.json | 14 +++++++ .../maya/server/settings/publishers.py | 13 +++++- 9 files changed, 72 insertions(+), 31 deletions(-) rename openpype/hosts/maya/plugins/publish/{extract_rig_fbx.py => extract_skeleton_mesh.py} (100%) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index d889fe4b8c..fed2887419 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4113,6 +4113,7 @@ def create_rig_animation_instance( node.endswith("skeletonAnim_SET")), None) if not anim_skeleton: log.debug("No skeletonAnim_SET in rig") + skeleton_mesh = next((node for node in nodes if node.endswith("skeletonMesh_SET")), None) if not skeleton_mesh: @@ -4128,8 +4129,9 @@ def create_rig_animation_instance( if custom_subset: formatting_data = { # TODO remove 'asset_type' and replace 'asset_name' with 'asset' - "asset_name": context['asset']['name'], - "asset_type": context['asset']['type'], + # "asset_name": context['asset']['name'], + # "asset_type": context['asset']['type'], + "asset": context["asset"], "subset": context['subset']['name'], "family": ( context['subset']['data'].get('family') or diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 75e36e78ce..061619dfb1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -1,17 +1,22 @@ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OptionalPyblishPluginMixin - -class CollectFbxAnimation(pyblish.api.InstancePlugin): +class CollectFbxAnimation(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Collect Animated Rig Data for FBX Extractor.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Fbx Animation" hosts = ["maya"] families = ["animation"] + optional = True def process(self, instance): + if not self.is_active(instance.data): + return skeleton_sets = [ i for i in instance if i.lower().endswith("skeletonanim_set") diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index ccf65441a2..5d894c99a0 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -29,6 +29,7 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): "no skeleton_set or skeleton_mesh set was found....") return instance.data["skeleton_mesh"] = [] + instance.data["skeleton_rig"] = [] if skeleton_mesh_sets: instance.data["families"] += ["rig.fbx"] @@ -40,3 +41,12 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): self.log.debug( "Collected skeleton " f"mesh Set: {skeleton_mesh_content}") + + if skeleton_sets: + for skeleton_set in skeleton_sets: + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug( + "Collected animated " + f"skeleton data: {skeleton_content}") + if skeleton_content: + instance.data["skeleton_rig"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 142d815a29..1c0a0135d2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -5,12 +5,10 @@ from maya import cmds # noqa import pyblish.api from openpype.pipeline import publish -from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.maya.api import fbx -class ExtractRigFBX(publish.Extractor, - OptionalPyblishPluginMixin): +class ExtractFBXAnimation(publish.Extractor): """Extract Rig in FBX format from Maya. This extracts the rig in fbx with the constraints @@ -25,8 +23,6 @@ class ExtractRigFBX(publish.Extractor, families = ["animation.fbx"] def process(self, instance): - if not self.is_active(instance.data): - return # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig_fbx.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py similarity index 100% rename from openpype/hosts/maya/plugins/publish/extract_rig_fbx.py rename to openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 0406b00ec6..8b8800af17 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -12,7 +12,8 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): The rigs optionally contain at least two object sets: "skeletonAnim_SET" - Set of only bone hierarchies - "skeletonMesh_SET" - Set of all cacheable meshes + "skeletonMesh_SET" - Set of the skinned meshes + with bone hierarchies """ @@ -21,11 +22,10 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): hosts = ["maya"] families = ["rig.fbx"] - accepted_output = ["mesh", "transform"] - accepted_controllers = ["transform"] + accepted_output = ["mesh", "transform", "locator"] + accepted_controllers = ["transform", "locator"] def process(self, instance): - objectsets = ["skeletonAnim_SET", "skeletonMesh_SET"] missing = [ key for key in objectsets if key not in instance.data["rig_sets"] @@ -36,8 +36,8 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): ) return - controls_set = instance.data["rig_sets"]["skeletonAnim_SET"] - out_set = instance.data["rig_sets"]["skeletonMesh_SET"] + skeleton_anim_set = instance.data["rig_sets"]["skeletonAnim_SET"] + skeleton_mesh_set = instance.data["rig_sets"]["skeletonMesh_SET"] # Ensure there are at least some transforms or dag nodes # in the rig instance set_members = instance.data['setMembers'] @@ -45,13 +45,13 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): self.log.debug("Skipping empty instance...") return # Ensure contents in sets and retrieve long path for all objects - output_content = cmds.sets( - out_set, query=True) or [] - output_content = cmds.ls(output_content, long=True) + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + skeleton_mesh_content = cmds.ls(skeleton_mesh_content, long=True) - controls_content = cmds.sets( - controls_set, query=True) or [] - controls_content = cmds.ls(controls_content, long=True) + skeleton_anim_content = cmds.sets( + skeleton_anim_set, query=True) or [] + skeleton_anim_content = cmds.ls(skeleton_anim_content, long=True) # Validate members are inside the hierarchy from root node root_node = cmds.ls(set_members, assemblies=True) @@ -60,16 +60,16 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): hierarchy = set(hierarchy) invalid_hierarchy = [] - if output_content: - for node in output_content: + if skeleton_mesh_content: + for node in skeleton_mesh_content: if node not in hierarchy: invalid_hierarchy.append(node) - invalid_geometry = self.validate_geometry(output_content) - if controls_content: - for node in controls_content: + invalid_geometry = self.validate_geometry(skeleton_mesh_content) + if skeleton_anim_content: + for node in skeleton_anim_content: if node not in hierarchy: invalid_hierarchy.append(node) - invalid_controls = self.validate_controls(controls_content) + invalid_controls = self.validate_controls(skeleton_anim_content) error = False if invalid_hierarchy: @@ -99,7 +99,7 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_SET + set_members: list of nodes of the skeleton_mesh_set hierarchy: list of nodes which reside under the root node Returns: @@ -126,7 +126,7 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): Checks if the node types of the set members valid Args: - set_members: list of nodes of the controls_SET + set_members: list of nodes of the skeleton_anim_set hierarchy: list of nodes which reside under the root node Returns: diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 022b906c4f..f4fb38ab53 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -707,6 +707,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxAnimation": { + "enabled": true + }, "CollectFbxCamera": { "enabled": false }, @@ -1141,7 +1144,7 @@ "active": true }, "ValidateSkeletonRigContents": { - "enabled": false, + "enabled": true, "optional": true, "active": true }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index e5fe367e77..6d81f38aa9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxAnimation", + "label": "Collect Fbx Animation", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index 0c733d9cbc..d82daa178c 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -129,6 +129,10 @@ class CollectMayaRenderModel(BaseSettingsModel): ) +class CollectFbxAnimationModel(BaseSettingsModel): + enabled: bool = Field(title="Collect Fbx Animation") + + class CollectFbxCameraModel(BaseSettingsModel): enabled: bool = Field(title="CollectFbxCamera") @@ -364,6 +368,10 @@ class PublishersModel(BaseSettingsModel): title="Collect Render Layers", section="Collectors" ) + CollectFbxAnimation: CollectFbxAnimationModel = Field( + default_factory=CollectFbxAnimationModel, + title="Collect FBX Animation", + ) CollectFbxCamera: CollectFbxCameraModel = Field( default_factory=CollectFbxCameraModel, title="Collect Camera for FBX export", @@ -768,6 +776,9 @@ DEFAULT_PUBLISH_SETTINGS = { "CollectMayaRender": { "sync_workfile_version": False }, + "CollectFbxAnimation": { + "enabled": True + }, "CollectFbxCamera": { "enabled": False }, @@ -1184,7 +1195,7 @@ DEFAULT_PUBLISH_SETTINGS = { "active": True }, "ValidateSkeletonRigContents": { - "enabled": False, + "enabled": True, "optional": True, "active": True }, From 02a1602e30c0f6b27237c4fa5ad7f9bcd7238631 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 21 Sep 2023 18:57:34 +0800 Subject: [PATCH 083/327] grab the data from the colorspace settings instead of allowing the users set colorspace --- .../hosts/max/plugins/create/create_render.py | 28 +---------------- .../max/plugins/publish/collect_render.py | 7 ++--- .../max/plugins/publish/collect_review.py | 30 +++---------------- 3 files changed, 8 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index b22f016c7c..9cc3c8da8a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" +import os from openpype.hosts.max.api import plugin from openpype.hosts.max.api.lib_rendersettings import RenderSettings -from openpype.hosts.max.api.lib import get_max_version -from openpype.lib import EnumDef -from pymxs import runtime as rt class CreateRender(plugin.MaxCreator): @@ -31,27 +29,3 @@ class CreateRender(plugin.MaxCreator): RenderSettings(self.project_settings).set_render_camera(sel_obj) # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) - - def get_instance_attr_defs(self): - if int(get_max_version()) >= 2024: - default_value = "" - display_views = [] - colorspace_mgr = rt.ColorPipelineMgr - for display in sorted(colorspace_mgr.GetDisplayList()): - for view in sorted(colorspace_mgr.GetViewList(display)): - display_views.append({ - "value": "||".join((display, view)) - }) - if display == "ACES" and view == "sRGB": - default_value = "{0}||{1}".format( - display, view - ) - else: - display_views = ["sRGB||ACES 1.0 SDR-video"] - - return [ - EnumDef("ocio_display_view_transform", - display_views, - default=default_value, - label="OCIO Displays and Views") - ] diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 38fa3843ca..1430ab1094 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -71,10 +71,9 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceView"] = "ACES 1.0 SDR-video" if int(get_max_version()) >= 2024: - creator_attribute = instance.data["creator_attributes"] - display_view_transform = creator_attribute["ocio_display_view_transform"] # noqa - display, view_transform = display_view_transform.split("||", 1) - colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next((display for display in colorspace_mgr.GetDisplayList())) + view_transform = next((view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 686dc2ed2c..bb85b3ba2b 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -3,7 +3,7 @@ import pyblish.api from pymxs import runtime as rt -from openpype.lib import BoolDef, EnumDef +from openpype.lib import BoolDef from openpype.hosts.max.api.lib import get_max_version from openpype.pipeline.publish import OpenPypePyblishPluginMixin @@ -46,10 +46,9 @@ class CollectReview(pyblish.api.InstancePlugin, } if int(get_max_version()) >= 2024: - display_view_transform = attr_values.get( - "ocio_display_view_transform") - display, view_transform = display_view_transform.split("||", 1) - colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next((display for display in colorspace_mgr.GetDisplayList())) + view_transform = next((view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform @@ -65,28 +64,7 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - default_value = "" - display_views = [] - if int(get_max_version()) >= 2024: - colorspace_mgr = rt.ColorPipelineMgr - displays = colorspace_mgr.GetDisplayList() - for display in sorted(displays): - views = colorspace_mgr.GetViewList(display) - for view in sorted(views): - display_views.append({ - "value": "||".join((display, view)) - }) - if display == "ACES" and view == "sRGB": - default_value = "{0}||{1}".format( - display, view - ) - else: - display_views = ["sRGB||ACES 1.0 SDR-video"] return [ - EnumDef("ocio_display_view_transform", - items=display_views, - default=default_value, - label="OCIO Displays and Views"), BoolDef("dspGeometry", label="Geometry", default=True), From e6db06c5763df15829c1c8c152f29684c524e093 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 21 Sep 2023 18:59:17 +0800 Subject: [PATCH 084/327] hound --- openpype/hosts/max/plugins/publish/collect_render.py | 6 ++++-- openpype/hosts/max/plugins/publish/collect_review.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 1430ab1094..7d2b080bcc 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -72,8 +72,10 @@ class CollectRender(pyblish.api.InstancePlugin): if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next((display for display in colorspace_mgr.GetDisplayList())) - view_transform = next((view for view in colorspace_mgr.GetViewList(display))) + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index bb85b3ba2b..cd6675e483 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -47,8 +47,10 @@ class CollectReview(pyblish.api.InstancePlugin, if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next((display for display in colorspace_mgr.GetDisplayList())) - view_transform = next((view for view in colorspace_mgr.GetViewList(display))) + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform From 61e5005da6657571e2ac695feaac424b0351e7b0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 21 Sep 2023 19:11:03 +0800 Subject: [PATCH 085/327] hound --- openpype/hosts/max/plugins/publish/collect_render.py | 2 +- openpype/hosts/max/plugins/publish/collect_review.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7d2b080bcc..a359e61921 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -72,7 +72,7 @@ class CollectRender(pyblish.api.InstancePlugin): if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next( + display = next( (display for display in colorspace_mgr.GetDisplayList())) view_transform = next( (view for view in colorspace_mgr.GetViewList(display))) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index cd6675e483..8e27a857d7 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -47,7 +47,7 @@ class CollectReview(pyblish.api.InstancePlugin, if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next( + display = next( (display for display in colorspace_mgr.GetDisplayList())) view_transform = next( (view for view in colorspace_mgr.GetViewList(display))) From 7dd64ff210078716b80271ab33e81a4ba7266993 Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 24 Sep 2023 13:07:03 +0800 Subject: [PATCH 086/327] temporarily remove namespace for fbx export and restore namespace after export --- .../plugins/publish/extract_fbx_animation.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 1c0a0135d2..1d683b2eb7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -30,6 +30,8 @@ class ExtractFBXAnimation(publish.Extractor): # The export requires forward slashes because we need # to format it into a string in a mel expression + + fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("animated_skeleton", []) # Export @@ -38,7 +40,21 @@ class ExtractFBXAnimation(publish.Extractor): instance.data["referencedAssetsContent"] = True fbx_exporter.set_options_from_instance(instance) - fbx_exporter.export(out_set, path.replace("\\", "/")) + + out_set_name = next(out for out in out_set) + # temporarily disable namespace + namespace = out_set_name.split(":")[0] + new_out_set = out_set_name.replace( + f"{namespace}:", "") + cmds.namespace(set=':') + cmds.namespace(set=namespace) + cmds.namespace(rel=True) + + fbx_exporter.export( + new_out_set, path.replace("\\", "/")) + # restore namespace after export + cmds.namespace(set=':') + cmds.namespace(rel=False) if "representations" not in instance.data: instance.data["representations"] = [] From ce104345236e42fa53ba428672a6c810b60007cb Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 24 Sep 2023 13:09:04 +0800 Subject: [PATCH 087/327] hound --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 1d683b2eb7..fb7001bb99 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -30,8 +30,6 @@ class ExtractFBXAnimation(publish.Extractor): # The export requires forward slashes because we need # to format it into a string in a mel expression - - fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("animated_skeleton", []) # Export From 72737702b6fd92a066ea51191752c6d9d10d57f9 Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 24 Sep 2023 13:10:08 +0800 Subject: [PATCH 088/327] hound --- openpype/hosts/maya/plugins/publish/collect_fbx_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 061619dfb1..d89236a73c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api -from openpype.lib import BoolDef from openpype.pipeline import OptionalPyblishPluginMixin + class CollectFbxAnimation(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Collect Animated Rig Data for FBX Extractor.""" From 131a55b32c880d4d9983d529aea97ae926aed183 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 11:22:58 +0200 Subject: [PATCH 089/327] :bug: add colorspace argument --- .../hosts/maya/plugins/publish/extract_look.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 74fcb58d29..b079d2cd62 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -176,6 +176,21 @@ class MakeRSTexBin(TextureProcessor): source ] + + # if color management is enabled we pass color space information + if color_management["enabled"]: + config_path = color_management["config"] + if not os.path.exists(config_path): + raise RuntimeError("OCIO config not found at: " + "{}".format(config_path)) + + # render_colorspace = color_management["rendering_space"] + + self.log.debug("converting colorspace {0} to redshift render " + "colorspace".format(colorspace)) + subprocess_args.extend(["-cs", colorspace]) + + hash_args = ["rstex"] texture_hash = source_hash(source, *hash_args) From 2a9d90d742f38f8abd5bb0deee224d2f469ac9c1 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 11:38:23 +0200 Subject: [PATCH 090/327] :dog: hound fix --- openpype/hosts/maya/plugins/publish/extract_look.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index b079d2cd62..cf1dd90416 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -176,7 +176,6 @@ class MakeRSTexBin(TextureProcessor): source ] - # if color management is enabled we pass color space information if color_management["enabled"]: config_path = color_management["config"] From 54e9efba95192b3f34ab03f50b992535ee56e72c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 12:51:38 +0200 Subject: [PATCH 091/327] :recycle: remove comment --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index cf1dd90416..1660c7b663 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -183,8 +183,6 @@ class MakeRSTexBin(TextureProcessor): raise RuntimeError("OCIO config not found at: " "{}".format(config_path)) - # render_colorspace = color_management["rendering_space"] - self.log.debug("converting colorspace {0} to redshift render " "colorspace".format(colorspace)) subprocess_args.extend(["-cs", colorspace]) From 49af8c21ecc4d88153f0f438873528948409fa8f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 14:03:04 +0200 Subject: [PATCH 092/327] :bug: add quotes to colorspace name --- openpype/hosts/maya/plugins/publish/extract_look.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 1660c7b663..9ef847e350 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -185,8 +185,7 @@ class MakeRSTexBin(TextureProcessor): self.log.debug("converting colorspace {0} to redshift render " "colorspace".format(colorspace)) - subprocess_args.extend(["-cs", colorspace]) - + subprocess_args.extend(["-cs", '"{}"'.format(colorspace)]) hash_args = ["rstex"] texture_hash = source_hash(source, *hash_args) From 7ed5442f3b4d762b9211664ff7358c9b43635595 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 15:22:04 +0200 Subject: [PATCH 093/327] :recycle: passing logger to run_subprocess to get more info --- openpype/hosts/maya/plugins/publish/extract_look.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 9ef847e350..e6ae2530c2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -183,9 +183,16 @@ class MakeRSTexBin(TextureProcessor): raise RuntimeError("OCIO config not found at: " "{}".format(config_path)) + if not os.getenv("OCIO"): + self.log.warning( + "OCIO environment variable not set." + "Setting it with OCIO config from OpenPype/AYON Settings." + ) + os.environ["OCIO"] = config_path + self.log.debug("converting colorspace {0} to redshift render " "colorspace".format(colorspace)) - subprocess_args.extend(["-cs", '"{}"'.format(colorspace)]) + subprocess_args.extend(["-cs", colorspace]) hash_args = ["rstex"] texture_hash = source_hash(source, *hash_args) @@ -197,10 +204,11 @@ class MakeRSTexBin(TextureProcessor): self.log.debug(" ".join(subprocess_args)) try: - run_subprocess(subprocess_args) + output = run_subprocess(subprocess_args, logger=self.log) except Exception: self.log.error("Texture .rstexbin conversion failed", exc_info=True) + self.log.debug(output) raise return TextureResult( From 6a4ab981ad2a9f5b6f9d3225a260bb43e7d4ac9b Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 25 Sep 2023 23:00:25 +0800 Subject: [PATCH 094/327] add validator to make sure all nodes are refernce nodes in skeleton_Anim_SET --- .../publish/validate_animated_reference.py | 31 +++++++++++++++++++ .../defaults/project_settings/maya.json | 5 +++ .../schemas/schema_maya_publish.json | 4 +++ .../maya/server/settings/publishers.py | 9 ++++++ 4 files changed, 49 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/validate_animated_reference.py diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py new file mode 100644 index 0000000000..8bf9c61d0d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -0,0 +1,31 @@ +import pyblish.api +import openpype.hosts.maya.api.action +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder +) +from maya import cmds + + +class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): + """ + Validate all the nodes underneath skeleton_Anim_SET + should be reference nodes + """ + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["animation.fbx"] + label = "Animated Reference Rig" + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + def process(self, instance): + animated_sets = instance.data["animated_skeleton"] + for animated_reference in animated_sets: + is_referenced = cmds.referenceQuery( + animated_reference, isNodeReferenced=True) + if not bool(is_referenced): + raise PublishValidationError( + "All the content in skeleton_Anim_SET" + " should be reference nodes" + ) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f4fb38ab53..d3e01287e5 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1123,6 +1123,11 @@ "optional": true, "active": true }, + "ValidateAnimatedReferenceRig": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateAnimationContent": { "enabled": true, "optional": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 6d81f38aa9..f2bbc0f70b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -807,6 +807,10 @@ "key": "ValidateRigControllers", "label": "Validate Rig Controllers" }, + { + "key": "ValidateAnimatedReferenceRig", + "label": "Validate Animated Reference Rig" + }, { "key": "ValidateAnimationContent", "label": "Validate Animation Content" diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index d82daa178c..cb3af191a8 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -652,6 +652,10 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Rig Controllers", ) + ValidateAnimatedReferenceRig: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Animated Reference Rig", + ) ValidateAnimationContent: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Animation Content", @@ -1174,6 +1178,11 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateAnimatedReferenceRig": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateAnimationContent": { "enabled": True, "optional": False, From a352a6468022cf46febfeaf19c19ba93d5e0d59c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 25 Sep 2023 20:16:56 +0300 Subject: [PATCH 095/327] add JOB path houdini setting --- .../defaults/project_settings/houdini.json | 6 ++++ .../schema_project_houdini.json | 4 +++ .../schemas/schema_houdini_general.json | 28 +++++++++++++++++++ .../houdini/server/settings/general.py | 22 +++++++++++++++ server_addon/houdini/server/settings/main.py | 10 ++++++- server_addon/houdini/server/version.py | 2 +- 6 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json create mode 100644 server_addon/houdini/server/settings/general.py diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 5392fc34dd..2b7244ac85 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,4 +1,10 @@ { + "general": { + "job_path": { + "enabled": true, + "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + } + }, "imageio": { "activate_host_color_management": true, "ocio_config": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json index 7f782e3647..d4d0565ec9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -5,6 +5,10 @@ "label": "Houdini", "is_file": true, "children": [ + { + "type": "schema", + "name": "schema_houdini_general" + }, { "key": "imageio", "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json new file mode 100644 index 0000000000..c275714ac7 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -0,0 +1,28 @@ +{ + "type": "dict", + "key": "general", + "label": "General", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "job_path", + "label": "JOB Path", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "path", + "label": "Path" + } + ] + } + ] +} diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py new file mode 100644 index 0000000000..242093deeb --- /dev/null +++ b/server_addon/houdini/server/settings/general.py @@ -0,0 +1,22 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + +class JobPathModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + path: str = Field(title="Path") + + +class GeneralSettingsModel(BaseSettingsModel): + JobPath: JobPathModel = Field( + default_factory=JobPathModel, + title="JOB Path" + ) + + +DEFAULT_GENERAL_SETTINGS = { + "JobPath": { + "enabled": True, + "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + } +} diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index fdb6838f5c..0c2e160c87 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -4,7 +4,10 @@ from ayon_server.settings import ( MultiplatformPathModel, MultiplatformPathListModel, ) - +from .general import ( + GeneralSettingsModel, + DEFAULT_GENERAL_SETTINGS +) from .imageio import HoudiniImageIOModel from .publish_plugins import ( PublishPluginsModel, @@ -52,6 +55,10 @@ class ShelvesModel(BaseSettingsModel): class HoudiniSettings(BaseSettingsModel): + general: GeneralSettingsModel = Field( + default_factory=GeneralSettingsModel, + title="General" + ) imageio: HoudiniImageIOModel = Field( default_factory=HoudiniImageIOModel, title="Color Management (ImageIO)" @@ -73,6 +80,7 @@ class HoudiniSettings(BaseSettingsModel): DEFAULT_VALUES = { + "general": DEFAULT_GENERAL_SETTINGS, "shelves": [], "create": DEFAULT_HOUDINI_CREATE_SETTINGS, "publish": DEFAULT_HOUDINI_PUBLISH_SETTINGS diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" From fef45ceea24754049ab7acbafe0a5bb655eca497 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 25 Sep 2023 20:31:44 +0300 Subject: [PATCH 096/327] implement get_current_context_template_data function --- openpype/pipeline/context_tools.py | 43 +++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index f567118062..13b14f1296 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -25,7 +25,10 @@ from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy -from .template_data import get_template_data_with_names +from .template_data import ( + get_template_data_with_names, + get_template_data +) from .workfile import ( get_workfile_template_key, get_custom_workfile_template_by_string_context, @@ -658,3 +661,41 @@ def get_process_id(): if _process_id is None: _process_id = str(uuid.uuid4()) return _process_id + + +def get_current_context_template_data(): + """Template data for template fill from current context + + Returns: + Dict[str, str] of the following tokens and their values + - app + - user + - asset + - parent + - hierarchy + - folder[name] + - root[work, ...] + - studio[code, name] + - project[code, name] + - task[type, name, short] + """ + + # pre-prepare get_template_data args + current_context = get_current_context() + project_name = current_context["project_name"] + asset_name = current_context["asset_name"] + anatomy = Anatomy(project_name) + + # prepare get_template_data args + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + task_name = current_context["task_name"] + host_name = get_current_host_name() + + # get template data + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + + template_data["root"] = anatomy.roots + return template_data From 59a20fe0fb77d32af3165927d3b0e0fd1d71be81 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 25 Sep 2023 21:58:11 +0300 Subject: [PATCH 097/327] implement validate_job_path and register it in houdini callbacks --- openpype/hosts/houdini/api/lib.py | 35 +++++++++++++++++++++++++- openpype/hosts/houdini/api/pipeline.py | 6 +++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a3f691e1fc..bdc8e0e973 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -9,9 +9,14 @@ import json import six +from openpype.lib import StringTemplate from openpype.client import get_asset_by_name +from openpype.settings import get_current_project_settings from openpype.pipeline import get_current_project_name, get_current_asset_name -from openpype.pipeline.context_tools import get_current_project_asset +from openpype.pipeline.context_tools import ( + get_current_context_template_data, + get_current_project_asset +) import hou @@ -747,3 +752,31 @@ def get_camera_from_container(container): assert len(cameras) == 1, "Camera instance must have only one camera" return cameras[0] + + +def validate_job_path(): + """Validate job path to ensure it matches the settings.""" + + project_settings = get_current_project_settings() + + if project_settings["houdini"]["general"]["job_path"]["enabled"]: + + # get and resolve job path template + job_path_template = project_settings["houdini"]["general"]["job_path"]["path"] + job_path = StringTemplate.format_template( + job_path_template, get_current_context_template_data() + ) + job_path = job_path.replace("\\","/") + + if job_path == "": + # Set JOB path to HIP path if JOB path is enabled + # and has empty value. + job_path = os.environ["HIP"] + + current_job = hou.hscript("echo -n `$JOB`")[0] + if current_job != job_path: + hou.hscript("set JOB=" + job_path) + os.environ["JOB"] = job_path + print(" - set $JOB to " + job_path) + else: + print(" - JOB Path is disabled, Skipping Check...") diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 6aa65deb89..48cc9e2150 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -300,6 +300,9 @@ def on_save(): log.info("Running callback on save..") + # Validate $JOB value + lib.validate_job_path() + nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) @@ -335,6 +338,9 @@ def on_open(): log.info("Running callback on open..") + # Validate $JOB value + lib.validate_job_path() + # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() From fdea715fe0d7aafd7f3000b8aca7780d432aeacb Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 25 Sep 2023 22:23:41 +0300 Subject: [PATCH 098/327] resolve hound --- openpype/hosts/houdini/api/lib.py | 5 +++-- server_addon/houdini/server/settings/general.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index bdc8e0e973..876d39b757 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -762,11 +762,12 @@ def validate_job_path(): if project_settings["houdini"]["general"]["job_path"]["enabled"]: # get and resolve job path template - job_path_template = project_settings["houdini"]["general"]["job_path"]["path"] + job_path_template = \ + project_settings["houdini"]["general"]["job_path"]["path"] job_path = StringTemplate.format_template( job_path_template, get_current_context_template_data() ) - job_path = job_path.replace("\\","/") + job_path = job_path.replace("\\", "/") if job_path == "": # Set JOB path to HIP path if JOB path is enabled diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 242093deeb..f5fed1c248 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -17,6 +17,6 @@ class GeneralSettingsModel(BaseSettingsModel): DEFAULT_GENERAL_SETTINGS = { "JobPath": { "enabled": True, - "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa } } From 8fd323fb16a52a2a0dbd3979cc4eaf7d37dca40d Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 26 Sep 2023 12:49:24 +0800 Subject: [PATCH 099/327] add the validation to make sure the skeleton_Anim_SET should be bone hierarchy only --- .../publish/validate_animated_reference.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index 8bf9c61d0d..0034599976 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -18,9 +18,15 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): families = ["animation.fbx"] label = "Animated Reference Rig" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + accepted_controllers = ["transform", "locator"] def process(self, instance): animated_sets = instance.data["animated_skeleton"] + if not animated_sets: + self.log.debug( + "No nodes found in skeleton_Anim_SET..Skipping..") + return + for animated_reference in animated_sets: is_referenced = cmds.referenceQuery( animated_reference, isNodeReferenced=True) @@ -29,3 +35,30 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): "All the content in skeleton_Anim_SET" " should be reference nodes" ) + invalid_controls = self.validate_controls(animated_sets) + if invalid_controls: + raise PublishValidationError( + "All the content in skeleton_Anim_SET" + " should be the transforms" + ) + def validate_controls(self, set_members): + """Check if the controller set passes the validations + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the skeleton_anim_set + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate control types + invalid = [] + for node in set_members: + if cmds.nodeType(node) not in self.accepted_controllers: + invalid.append(node) + + return invalid From b33ddb05de211e71151b95f139aae8f99c30e874 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 26 Sep 2023 12:51:14 +0800 Subject: [PATCH 100/327] hound --- .../maya/plugins/publish/validate_animated_reference.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index 0034599976..63c0b6958d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -38,9 +38,10 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): invalid_controls = self.validate_controls(animated_sets) if invalid_controls: raise PublishValidationError( - "All the content in skeleton_Anim_SET" - " should be the transforms" - ) + "All the content in skeleton_Anim_SET" + " should be the transforms" + ) + def validate_controls(self, set_members): """Check if the controller set passes the validations From f8463f6e9e30e1e96a50239c9a6e96cc3dbc2961 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 26 Sep 2023 15:11:00 +0200 Subject: [PATCH 101/327] :recycle: pass logger, :bug: fix `output` var --- openpype/hosts/maya/plugins/publish/extract_look.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index e6ae2530c2..d2e3e2c937 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -204,11 +204,10 @@ class MakeRSTexBin(TextureProcessor): self.log.debug(" ".join(subprocess_args)) try: - output = run_subprocess(subprocess_args, logger=self.log) - except Exception: + run_subprocess(subprocess_args, logger=self.log) + except Exception as e: self.log.error("Texture .rstexbin conversion failed", exc_info=True) - self.log.debug(output) raise return TextureResult( @@ -491,7 +490,7 @@ class ExtractLook(publish.Extractor): "rstex": MakeRSTexBin }.items(): if instance.data.get(key, False): - processor = Processor() + processor = Processor(log=self.log) processor.apply_settings(context.data["system_settings"], context.data["project_settings"]) processors.append(processor) From 499b4ddae529f2d2f2a880df08e7ff0bb320f617 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 26 Sep 2023 15:22:06 +0200 Subject: [PATCH 102/327] :recycle: reraise exception --- openpype/hosts/maya/plugins/publish/extract_look.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index d2e3e2c937..b2b3330df1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Maya look extractor.""" +import sys from abc import ABCMeta, abstractmethod from collections import OrderedDict import contextlib @@ -205,10 +206,10 @@ class MakeRSTexBin(TextureProcessor): self.log.debug(" ".join(subprocess_args)) try: run_subprocess(subprocess_args, logger=self.log) - except Exception as e: + except Exception: self.log.error("Texture .rstexbin conversion failed", exc_info=True) - raise + six.reraise(*sys.exc_info()) return TextureResult( path=destination, From d1395fe4099bc98dd3bbaf016029fc5d480d0a3c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 26 Sep 2023 16:22:18 +0300 Subject: [PATCH 103/327] update settings names --- openpype/hosts/houdini/api/lib.py | 6 +++--- .../defaults/project_settings/houdini.json | 4 ++-- .../schemas/schema_houdini_general.json | 8 ++++---- server_addon/houdini/server/settings/general.py | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 876d39b757..73a6f452d0 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -758,12 +758,12 @@ def validate_job_path(): """Validate job path to ensure it matches the settings.""" project_settings = get_current_project_settings() + project_settings = project_settings["houdini"]["general"]["update_job_var_context"] - if project_settings["houdini"]["general"]["job_path"]["enabled"]: + if project_settings["enabled"]: # get and resolve job path template - job_path_template = \ - project_settings["houdini"]["general"]["job_path"]["path"] + job_path_template = project_settings["job_path"] job_path = StringTemplate.format_template( job_path_template, get_current_context_template_data() ) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 2b7244ac85..5057db1f03 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,8 +1,8 @@ { "general": { - "job_path": { + "update_job_var_context": { "enabled": true, - "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + "job_path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" } }, "imageio": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index c275714ac7..eecc29592a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -9,8 +9,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "job_path", - "label": "JOB Path", + "key": "update_job_var_context", + "label": "Update $JOB on context change", "children": [ { "type": "boolean", @@ -19,8 +19,8 @@ }, { "type": "text", - "key": "path", - "label": "Path" + "key": "job_path", + "label": "JOB Path" } ] } diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index f5fed1c248..f47fa9c564 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -2,21 +2,21 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel -class JobPathModel(BaseSettingsModel): +class UpdateJobVarcontextModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - path: str = Field(title="Path") + job_path: str = Field(title="JOB Path") class GeneralSettingsModel(BaseSettingsModel): - JobPath: JobPathModel = Field( - default_factory=JobPathModel, - title="JOB Path" + update_job_var_context: UpdateJobVarcontextModel = Field( + default_factory=UpdateJobVarcontextModel, + title="Update $JOB on context change" ) DEFAULT_GENERAL_SETTINGS = { - "JobPath": { + "update_job_var_context": { "enabled": True, - "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa + "job_path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa } } From 30e2ecb8595213542626b51f8514101054d10fef Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 26 Sep 2023 16:26:14 +0300 Subject: [PATCH 104/327] BigRoy's comment --- openpype/hosts/houdini/api/lib.py | 7 +++---- openpype/hosts/houdini/api/pipeline.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 73a6f452d0..8624f09289 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -754,11 +754,12 @@ def get_camera_from_container(container): return cameras[0] -def validate_job_path(): +def update_job_var_context(): """Validate job path to ensure it matches the settings.""" project_settings = get_current_project_settings() - project_settings = project_settings["houdini"]["general"]["update_job_var_context"] + project_settings = \ + project_settings["houdini"]["general"]["update_job_var_context"] if project_settings["enabled"]: @@ -779,5 +780,3 @@ def validate_job_path(): hou.hscript("set JOB=" + job_path) os.environ["JOB"] = job_path print(" - set $JOB to " + job_path) - else: - print(" - JOB Path is disabled, Skipping Check...") diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 48cc9e2150..3efbbb12b3 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -301,7 +301,7 @@ def on_save(): log.info("Running callback on save..") # Validate $JOB value - lib.validate_job_path() + lib.update_job_var_context() nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): @@ -339,7 +339,7 @@ def on_open(): log.info("Running callback on open..") # Validate $JOB value - lib.validate_job_path() + lib.update_job_var_context() # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset From fd8daebed9bbb579c13f193253c00a5d7cf22005 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 26 Sep 2023 20:25:51 +0300 Subject: [PATCH 105/327] update log message --- openpype/hosts/houdini/api/lib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 8624f09289..c8211f45d2 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -779,4 +779,5 @@ def update_job_var_context(): if current_job != job_path: hou.hscript("set JOB=" + job_path) os.environ["JOB"] = job_path - print(" - set $JOB to " + job_path) + print(" - Context changed, update $JOB respectively to " + + job_path) From a349733ecf418f353f940969aea1ab0a6e1aaff8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 26 Sep 2023 20:38:05 +0300 Subject: [PATCH 106/327] sync $JOB and [JOB] --- openpype/hosts/houdini/api/lib.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index c8211f45d2..ac28163144 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -776,6 +776,12 @@ def update_job_var_context(): job_path = os.environ["HIP"] current_job = hou.hscript("echo -n `$JOB`")[0] + + # sync both environment variables. + # because when opening new file $JOB is overridden with + # the value saved in the HIP file but os.environ["JOB"] is not! + os.environ["JOB"] = current_job + if current_job != job_path: hou.hscript("set JOB=" + job_path) os.environ["JOB"] = job_path From f15dcb30b87daf23b3c0a087237cf50e7b26ddf8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 12:54:02 +0300 Subject: [PATCH 107/327] create JOB folder if not exists --- openpype/hosts/houdini/api/lib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ac28163144..9fe5ac83ce 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -785,5 +785,8 @@ def update_job_var_context(): if current_job != job_path: hou.hscript("set JOB=" + job_path) os.environ["JOB"] = job_path + + os.makedirs(job_path, exist_ok=True) + print(" - Context changed, update $JOB respectively to " + job_path) From edcaa8b62f0be86425da063efe55bc0377fa8c8c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:02:38 +0300 Subject: [PATCH 108/327] update docs --- website/docs/admin_hosts_houdini.md | 19 +++++++++++++++++- .../houdini/update-job-context-change.png | Bin 0 -> 8068 bytes 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 website/docs/assets/houdini/update-job-context-change.png diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 64c54db591..1e82dd97dd 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -3,9 +3,26 @@ id: admin_hosts_houdini title: Houdini sidebar_label: Houdini --- +## General Settings +### JOB Path +you can add your studios preffered JOB Path, JOB value will be checked and updated on file save and open. +Disableing this option will effectivly turn off this feature. + +JOB Path can be: +- Arbitrary hardcoded path +- Openpype template path + > This allows dynamic values for assets or shots.
+ > Using template keys is supported but formatting keys capitalization variants is not, + > e.g. {Asset} and {ASSET} won't work +- empty + > In this case, JOB will be synced to HIP + +![update job on context change](assets/houdini/update-job-context-change.png) + + ## Shelves Manager You can add your custom shelf set into Houdini by setting your shelf sets, shelves and tools in **Houdini -> Shelves Manager**. ![Custom menu definition](assets/houdini-admin_shelvesmanager.png) -The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. \ No newline at end of file +The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. diff --git a/website/docs/assets/houdini/update-job-context-change.png b/website/docs/assets/houdini/update-job-context-change.png new file mode 100644 index 0000000000000000000000000000000000000000..0faf317a227291024d95d7a60b9f1572ec57995a GIT binary patch literal 8068 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 literal 0 HcmV?d00001 From ef785e75e39d581a86cbcec76f151cd26cde6ebd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:31:47 +0300 Subject: [PATCH 109/327] update os.makedirs --- openpype/hosts/houdini/api/lib.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 9fe5ac83ce..abc6d5d0f5 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys import os +import errno import re import uuid import logging @@ -786,7 +787,14 @@ def update_job_var_context(): hou.hscript("set JOB=" + job_path) os.environ["JOB"] = job_path - os.makedirs(job_path, exist_ok=True) + try: + os.makedirs(job_path) + except OSError as e: + if e.errno != errno.EEXIST: + print( + " - Failed to create JOB dir. Maybe due to " + "insufficient permissions." + ) print(" - Context changed, update $JOB respectively to " + job_path) From a3cb6c445684c5859721a2cdf5961061705d906f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:33:06 +0300 Subject: [PATCH 110/327] resolve hound --- openpype/hosts/houdini/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index abc6d5d0f5..1b04fd692a 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -792,8 +792,8 @@ def update_job_var_context(): except OSError as e: if e.errno != errno.EEXIST: print( - " - Failed to create JOB dir. Maybe due to " - "insufficient permissions." + " - Failed to create JOB dir. Maybe due to " + "insufficient permissions." ) print(" - Context changed, update $JOB respectively to " From 05cef1ed91d1982edd6e0cf8143025f0968371d5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:38:52 +0300 Subject: [PATCH 111/327] Minikiu comments --- openpype/hosts/houdini/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 1b04fd692a..1ea71fa2a7 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -759,15 +759,15 @@ def update_job_var_context(): """Validate job path to ensure it matches the settings.""" project_settings = get_current_project_settings() - project_settings = \ + job_var_settings = \ project_settings["houdini"]["general"]["update_job_var_context"] - if project_settings["enabled"]: + if job_var_settings["enabled"]: # get and resolve job path template - job_path_template = project_settings["job_path"] job_path = StringTemplate.format_template( - job_path_template, get_current_context_template_data() + job_var_settings["job_path"], + get_current_context_template_data() ) job_path = job_path.replace("\\", "/") From 260650ea43b7850ead2a2820b02bca05eef8d710 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:51:50 +0300 Subject: [PATCH 112/327] update docs --- website/docs/admin_hosts_houdini.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 1e82dd97dd..9c9536a26e 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -5,16 +5,18 @@ sidebar_label: Houdini --- ## General Settings ### JOB Path -you can add your studios preffered JOB Path, JOB value will be checked and updated on file save and open. -Disableing this option will effectivly turn off this feature. +Specify a studio-wide `JOB` path.
+The Houdini `$JOB` path can be customized through project settings with a (dynamic) path that will be updated on context changes, e.g. when switching to another asset or task. + +Disabling this feature will leave `$JOB` var unmanaged and thus no context update changes will occur. JOB Path can be: -- Arbitrary hardcoded path +- Arbitrary path - Openpype template path > This allows dynamic values for assets or shots.
> Using template keys is supported but formatting keys capitalization variants is not, > e.g. {Asset} and {ASSET} won't work -- empty +- Empty > In this case, JOB will be synced to HIP ![update job on context change](assets/houdini/update-job-context-change.png) From 346544df3cb84a3db153945d768dc8175a36957e Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 17:23:33 +0300 Subject: [PATCH 113/327] update docs 2 --- website/docs/admin_hosts_houdini.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 9c9536a26e..2d345d2d76 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -5,9 +5,11 @@ sidebar_label: Houdini --- ## General Settings ### JOB Path -Specify a studio-wide `JOB` path.
+ The Houdini `$JOB` path can be customized through project settings with a (dynamic) path that will be updated on context changes, e.g. when switching to another asset or task. +> If the folder does not exist on the context change it will be created by this feature so that `$JOB` will always try to point to an existing folder. + Disabling this feature will leave `$JOB` var unmanaged and thus no context update changes will occur. JOB Path can be: From 67964bec3aadcf8035b966c6a77ab586a1f797c3 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 17:47:08 +0300 Subject: [PATCH 114/327] fix format --- website/docs/admin_hosts_houdini.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 2d345d2d76..75b0922dac 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -17,7 +17,7 @@ JOB Path can be: - Openpype template path > This allows dynamic values for assets or shots.
> Using template keys is supported but formatting keys capitalization variants is not, - > e.g. {Asset} and {ASSET} won't work + > e.g. `{Asset}` and `{ASSET}` won't work - Empty > In this case, JOB will be synced to HIP From 61ce75f0c9e23f9d7f36d5bd2d7fc10fe5143514 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 19:12:56 +0300 Subject: [PATCH 115/327] BigRoy's comment --- openpype/hosts/houdini/api/lib.py | 8 +++++--- website/docs/admin_hosts_houdini.md | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 1ea71fa2a7..5302fbea74 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -756,7 +756,10 @@ def get_camera_from_container(container): def update_job_var_context(): - """Validate job path to ensure it matches the settings.""" + """Update $JOB to match current context. + + This will only do something if the setting is enabled in project settings. + """ project_settings = get_current_project_settings() job_var_settings = \ @@ -796,5 +799,4 @@ def update_job_var_context(): "insufficient permissions." ) - print(" - Context changed, update $JOB respectively to " - + job_path) + print(" - Updated $JOB to {}".format(job_path)) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 75b0922dac..ea7991530b 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -8,7 +8,9 @@ sidebar_label: Houdini The Houdini `$JOB` path can be customized through project settings with a (dynamic) path that will be updated on context changes, e.g. when switching to another asset or task. -> If the folder does not exist on the context change it will be created by this feature so that `$JOB` will always try to point to an existing folder. +:::note +If the folder does not exist on the context change it will be created by this feature so that `$JOB` will always try to point to an existing folder. +::: Disabling this feature will leave `$JOB` var unmanaged and thus no context update changes will occur. From 7197134954f4490dcb84032055d17495e4e189a2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 28 Sep 2023 00:46:47 +0300 Subject: [PATCH 116/327] Allow adding more Houdini vars --- openpype/hosts/houdini/api/lib.py | 74 +++++++++++-------- openpype/hosts/houdini/api/pipeline.py | 8 +- .../defaults/project_settings/houdini.json | 6 +- .../schemas/schema_houdini_general.json | 15 ++-- .../houdini/server/settings/general.py | 29 ++++++-- 5 files changed, 82 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 5302fbea74..f8d17eef07 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -755,48 +755,58 @@ def get_camera_from_container(container): return cameras[0] -def update_job_var_context(): - """Update $JOB to match current context. +def update_houdini_vars_context(): + """Update Houdini vars to match current context. This will only do something if the setting is enabled in project settings. """ project_settings = get_current_project_settings() - job_var_settings = \ - project_settings["houdini"]["general"]["update_job_var_context"] + houdini_vars_settings = \ + project_settings["houdini"]["general"]["update_houdini_var_context"] - if job_var_settings["enabled"]: + if houdini_vars_settings["enabled"]: + houdini_vars = houdini_vars_settings["houdini_vars"] - # get and resolve job path template - job_path = StringTemplate.format_template( - job_var_settings["job_path"], - get_current_context_template_data() - ) - job_path = job_path.replace("\\", "/") + # Remap AYON settings structure to OpenPype settings structure + # It allows me to use the same logic for both AYON and OpenPype + if isinstance(houdini_vars, list): + items = {} + for item in houdini_vars: + items.update({item["var"]: item["path"]}) - if job_path == "": - # Set JOB path to HIP path if JOB path is enabled - # and has empty value. - job_path = os.environ["HIP"] + houdini_vars = items - current_job = hou.hscript("echo -n `$JOB`")[0] + for var, path in houdini_vars.items(): + # get and resolve job path template + path = StringTemplate.format_template( + path, + get_current_context_template_data() + ) + path = path.replace("\\", "/") - # sync both environment variables. - # because when opening new file $JOB is overridden with - # the value saved in the HIP file but os.environ["JOB"] is not! - os.environ["JOB"] = current_job + if var == "JOB" and path == "": + # sync $JOB to $HIP if $JOB is empty + path = os.environ["HIP"] - if current_job != job_path: - hou.hscript("set JOB=" + job_path) - os.environ["JOB"] = job_path + current_path = hou.hscript("echo -n `${}`".format(var))[0] - try: - os.makedirs(job_path) - except OSError as e: - if e.errno != errno.EEXIST: - print( - " - Failed to create JOB dir. Maybe due to " - "insufficient permissions." - ) + # sync both environment variables. + # because houdini doesn't do that by default + # on opening new files + os.environ[var] = current_path - print(" - Updated $JOB to {}".format(job_path)) + if current_path != path: + hou.hscript("set {}={}".format(var, path)) + os.environ[var] = path + + try: + os.makedirs(path) + except OSError as e: + if e.errno != errno.EEXIST: + print( + " - Failed to create {} dir. Maybe due to " + "insufficient permissions.".format(var) + ) + + print(" - Updated ${} to {}".format(var, path)) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 3efbbb12b3..f753d518f0 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -300,8 +300,8 @@ def on_save(): log.info("Running callback on save..") - # Validate $JOB value - lib.update_job_var_context() + # update houdini vars + lib.update_houdini_vars_context() nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): @@ -338,8 +338,8 @@ def on_open(): log.info("Running callback on open..") - # Validate $JOB value - lib.update_job_var_context() + # update houdini vars + lib.update_houdini_vars_context() # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 5057db1f03..b2fcb708cf 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,8 +1,10 @@ { "general": { - "update_job_var_context": { + "update_houdini_var_context": { "enabled": true, - "job_path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + "houdini_vars":{ + "JOB": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" + } } }, "imageio": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index eecc29592a..127382f4bc 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -9,8 +9,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "update_job_var_context", - "label": "Update $JOB on context change", + "key": "update_houdini_var_context", + "label": "Update Houdini Vars on context change", "children": [ { "type": "boolean", @@ -18,9 +18,14 @@ "label": "Enabled" }, { - "type": "text", - "key": "job_path", - "label": "JOB Path" + "type": "dict-modifiable", + "key": "houdini_vars", + "label": "Houdini Vars", + "collapsible": false, + "object_type": { + "type": "path", + "multiplatform": false + } } ] } diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index f47fa9c564..42a071a688 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -2,21 +2,36 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel -class UpdateJobVarcontextModel(BaseSettingsModel): +class HoudiniVarModel(BaseSettingsModel): + _layout = "expanded" + var: str = Field("", title="Var") + path: str = Field(default_factory="", title="Path") + + +class UpdateHoudiniVarcontextModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - job_path: str = Field(title="JOB Path") + # TODO this was dynamic dictionary '{var: path}' + houdini_vars: list[HoudiniVarModel] = Field( + default_factory=list, + title="Houdini Vars" + ) class GeneralSettingsModel(BaseSettingsModel): - update_job_var_context: UpdateJobVarcontextModel = Field( - default_factory=UpdateJobVarcontextModel, - title="Update $JOB on context change" + update_houdini_var_context: UpdateHoudiniVarcontextModel = Field( + default_factory=UpdateHoudiniVarcontextModel, + title="Update Houdini Vars on context change" ) DEFAULT_GENERAL_SETTINGS = { - "update_job_var_context": { + "update_houdini_var_context": { "enabled": True, - "job_path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa + "houdini_vars": [ + { + "var": "JOB", + "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa + } + ] } } From ed02bf31114e885a157c8ef13f8474cc86d093a1 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 17:51:08 +0800 Subject: [PATCH 117/327] remove invalid actions and some code tweaks --- openpype/hosts/maya/plugins/publish/collect_fbx_animation.py | 2 +- .../hosts/maya/plugins/publish/validate_animated_reference.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index d89236a73c..ff7d068d7d 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -22,7 +22,7 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin, if i.lower().endswith("skeletonanim_set") ] if skeleton_sets: - instance.data["families"] += ["animation.fbx"] + instance.data["families"].append("animation.fbx") instance.data["animated_skeleton"] = [] for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index 63c0b6958d..c1b5a2852d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder @@ -17,7 +16,6 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): hosts = ["maya"] families = ["animation.fbx"] label = "Animated Reference Rig" - actions = [openpype.hosts.maya.api.action.SelectInvalidAction] accepted_controllers = ["transform", "locator"] def process(self, instance): From a433c46e727862a28a0a4835f582135b588e67e6 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 19:50:44 +0800 Subject: [PATCH 118/327] code tweak on extract fbx animation --- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index fb7001bb99..f8b7c18614 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -54,15 +54,12 @@ class ExtractFBXAnimation(publish.Extractor): cmds.namespace(set=':') cmds.namespace(rel=False) - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { + representations = instance.data.setdefault("representations", []) + representations.append({ 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir - } - instance.data["representations"].append(representation) + }) self.log.debug("Extract animated FBX successful to: {0}".format(path)) From dda932e83ef448c74533ea39f687a6d919a2551c Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:37:30 +0800 Subject: [PATCH 119/327] code clean up and tweak on debug mesg --- openpype/hosts/maya/api/lib.py | 11 ---------- .../hosts/maya/plugins/create/create_rig.py | 4 ++-- .../plugins/publish/collect_fbx_animation.py | 22 ++++++++++--------- .../plugins/publish/collect_skeleton_mesh.py | 22 +++++++++---------- .../plugins/publish/extract_fbx_animation.py | 17 +++++++------- .../plugins/publish/extract_skeleton_mesh.py | 12 +++++----- .../publish/validate_animated_reference.py | 15 +++++-------- .../publish/validate_skeleton_rig_content.py | 6 ++--- .../validate_skeleton_top_group_hierarchy.py | 10 ++++----- 9 files changed, 50 insertions(+), 69 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index fed2887419..03a864a1db 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4111,13 +4111,7 @@ def create_rig_animation_instance( anim_skeleton = next((node for node in nodes if node.endswith("skeletonAnim_SET")), None) - if not anim_skeleton: - log.debug("No skeletonAnim_SET in rig") - skeleton_mesh = next((node for node in nodes if - node.endswith("skeletonMesh_SET")), None) - if not skeleton_mesh: - log.debug("No skeletonMesh_SET in rig") # Find the roots amongst the loaded nodes roots = ( cmds.ls(nodes, assemblies=True, long=True) or @@ -4128,9 +4122,6 @@ def create_rig_animation_instance( custom_subset = options.get("animationSubsetName") if custom_subset: formatting_data = { - # TODO remove 'asset_type' and replace 'asset_name' with 'asset' - # "asset_name": context['asset']['name'], - # "asset_type": context['asset']['type'], "asset": context["asset"], "subset": context['subset']['name'], "family": ( @@ -4156,8 +4147,6 @@ def create_rig_animation_instance( rig_sets = [output, controls] if anim_skeleton: rig_sets.append(anim_skeleton) - if skeleton_mesh: - rig_sets.append(skeleton_mesh) with maintained_selection(): cmds.select(rig_sets + roots, noExpand=True) create_context.create( diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 69c7787905..22a94ed4fd 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -20,9 +20,9 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") - # change name (_controls_set -> _rigs_SET) + # TODO:change name (_controls_set -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) - # change name (_out_SET -> _geo_SET) + # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) skeleton = cmds.sets( name=subset_name + "_skeletonAnim_SET", empty=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index ff7d068d7d..9347936e63 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -21,13 +21,15 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin, i for i in instance if i.lower().endswith("skeletonanim_set") ] - if skeleton_sets: - instance.data["families"].append("animation.fbx") - instance.data["animated_skeleton"] = [] - for skeleton_set in skeleton_sets: - skeleton_content = cmds.sets(skeleton_set, query=True) - self.log.debug( - "Collected animated " - f"skeleton data: {skeleton_content}") - if skeleton_content: - instance.data["animated_skeleton"] += skeleton_content + if not skeleton_sets: + return + + instance.data["families"].append("animation.fbx") + instance.data["animated_skeleton"] = [] + for skeleton_set in skeleton_sets: + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug( + "Collected animated " + f"skeleton data: {skeleton_content}") + if skeleton_content: + instance.data["animated_skeleton"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index 5d894c99a0..73b2103618 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -12,22 +12,20 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): families = ["rig"] def process(self, instance): + skeleton_sets = instance.data.get("skeletonAnim_SET") + skeleton_mesh_sets = instance.data.get("skeletonMesh_SET") + if not skeleton_mesh_sets: + self.log.debug( + "skeletonMesh_SET found. " + "Skipping collecting of skeleton mesh..." + ) + return + + # Store current frame to ensure single frame export frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame - skeleton_sets = [ - i for i in instance - if i.lower().endswith("skeletonanim_set") - ] - skeleton_mesh_sets = [ - i for i in instance - if i.lower().endswith("skeletonmesh_set") - ] - if not skeleton_sets and skeleton_mesh_sets: - self.log.debug( - "no skeleton_set or skeleton_mesh set was found....") - return instance.data["skeleton_mesh"] = [] instance.data["skeleton_rig"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index f8b7c18614..748f30e43d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -6,6 +6,7 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import fbx +from openpype.hosts.maya.api.lib import namespaced class ExtractFBXAnimation(publish.Extractor): @@ -27,9 +28,8 @@ class ExtractFBXAnimation(publish.Extractor): staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) + path = path.replace("\\", "/") - # The export requires forward slashes because we need - # to format it into a string in a mel expression fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("animated_skeleton", []) # Export @@ -44,12 +44,11 @@ class ExtractFBXAnimation(publish.Extractor): namespace = out_set_name.split(":")[0] new_out_set = out_set_name.replace( f"{namespace}:", "") - cmds.namespace(set=':') - cmds.namespace(set=namespace) - cmds.namespace(rel=True) - - fbx_exporter.export( - new_out_set, path.replace("\\", "/")) + cmds.namespace(set=':' + namespace) + cmds.namespace(relativeNames=True) + with namespaced(":" + namespace, new=False) as namespace: + fbx_exporter.export( + new_out_set, path.replace("\\", "/")) # restore namespace after export cmds.namespace(set=':') cmds.namespace(rel=False) @@ -62,4 +61,4 @@ class ExtractFBXAnimation(publish.Extractor): "stagingDir": staging_dir }) - self.log.debug("Extract animated FBX successful to: {0}".format(path)) + self.log.debug("Extracted Fbx animation successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py index c9fe53f0be..42cbb33013 100644 --- a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -43,17 +43,15 @@ class ExtractSkeletonMesh(publish.Extractor, fbx_exporter.set_options_from_instance(instance) # Export - fbx_exporter.export(out_set, path.replace("\\", "/")) + path = path.replace("\\", "/") + fbx_exporter.export(out_set, path) - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { + representations = instance.data.setdefault("representations", []) + representations.append({ 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir - } - instance.data["representations"].append(representation) + }) self.log.debug("Extract animated FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index c1b5a2852d..3dc272d7cc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -7,10 +7,7 @@ from maya import cmds class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): - """ - Validate all the nodes underneath skeleton_Anim_SET - should be reference nodes - """ + """Validate all nodes in skeletonAnim_SET are referenced""" order = ValidateContentsOrder hosts = ["maya"] @@ -22,7 +19,7 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): animated_sets = instance.data["animated_skeleton"] if not animated_sets: self.log.debug( - "No nodes found in skeleton_Anim_SET..Skipping..") + "No nodes found in skeletonAnim_SET.Skipping...") return for animated_reference in animated_sets: @@ -30,14 +27,14 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): animated_reference, isNodeReferenced=True) if not bool(is_referenced): raise PublishValidationError( - "All the content in skeleton_Anim_SET" - " should be reference nodes" + "All the content in skeletonAnim_SET" + " should be referenced nodes" ) invalid_controls = self.validate_controls(animated_sets) if invalid_controls: raise PublishValidationError( - "All the content in skeleton_Anim_SET" - " should be the transforms" + "All the content in skeletonAnim_SET" + " should be transforms" ) def validate_controls(self, set_members): diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 8b8800af17..c7e724b569 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -45,12 +45,10 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): self.log.debug("Skipping empty instance...") return # Ensure contents in sets and retrieve long path for all objects - skeleton_mesh_content = cmds.sets( - skeleton_mesh_set, query=True) or [] + skeleton_mesh_content = instance.data.get("skeleton_mesh", []) skeleton_mesh_content = cmds.ls(skeleton_mesh_content, long=True) - skeleton_anim_content = cmds.sets( - skeleton_anim_set, query=True) or [] + skeleton_anim_content = instance.data.get("skeleton_rig", []) skeleton_anim_content = cmds.ls(skeleton_anim_content, long=True) # Validate members are inside the hierarchy from root node diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py index df434f132d..1e0d856b4e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -25,18 +25,18 @@ class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, def process(self, instance): invalid = [] skeleton_data = instance.data.get(("animated_rigs"), []) - skeletonMesh_data = instance.data(("skeleton_mesh"), []) + skeleton_mesh_data = instance.data(("skeleton_mesh"), []) if skeleton_data: invalid = self.get_top_hierarchy(skeleton_data) if invalid: raise PublishValidationError( - "The set includes the object which " + "The skeletonAnim_SET includes the object which " f"is not at the top hierarchy: {invalid}") - if skeletonMesh_data: - invalid = self.get_top_hierarchy(skeletonMesh_data) + if skeleton_mesh_data: + invalid = self.get_top_hierarchy(skeleton_mesh_data) if invalid: raise PublishValidationError( - "The set includes the object which " + "The skeletonMesh_SET includes the object which " f"is not at the top hierarchy: {invalid}") def get_top_hierarchy(self, targets): From 0829adceda7fb8258b47dc7eb9a94691e789c77b Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:41:07 +0800 Subject: [PATCH 120/327] hound --- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 6 ++---- .../maya/plugins/publish/validate_skeleton_rig_content.py | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 748f30e43d..e99e7d40bd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -49,9 +49,6 @@ class ExtractFBXAnimation(publish.Extractor): with namespaced(":" + namespace, new=False) as namespace: fbx_exporter.export( new_out_set, path.replace("\\", "/")) - # restore namespace after export - cmds.namespace(set=':') - cmds.namespace(rel=False) representations = instance.data.setdefault("representations", []) representations.append({ @@ -61,4 +58,5 @@ class ExtractFBXAnimation(publish.Extractor): "stagingDir": staging_dir }) - self.log.debug("Extracted Fbx animation successful to: {0}".format(path)) + self.log.debug( + "Extracted Fbx animation successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index c7e724b569..59595d5a1c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -36,8 +36,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): ) return - skeleton_anim_set = instance.data["rig_sets"]["skeletonAnim_SET"] - skeleton_mesh_set = instance.data["rig_sets"]["skeletonMesh_SET"] # Ensure there are at least some transforms or dag nodes # in the rig instance set_members = instance.data['setMembers'] From 276c6a81cd93509fbebc1808955275d9729d936f Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:42:51 +0800 Subject: [PATCH 121/327] message tweak --- .../hosts/maya/plugins/publish/validate_skeleton_rig_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 59595d5a1c..a620c2f631 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -40,7 +40,7 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): # in the rig instance set_members = instance.data['setMembers'] if not cmds.ls(set_members, type="dagNode", long=True): - self.log.debug("Skipping empty instance...") + self.log.debug("Skipping instance without dag nodes...") return # Ensure contents in sets and retrieve long path for all objects skeleton_mesh_content = instance.data.get("skeleton_mesh", []) From 63e294147652c999b1bd0d4325c601830afb9bac Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:46:19 +0800 Subject: [PATCH 122/327] remove skeleton_anim_set in collector and validation check on rig content --- .../plugins/publish/collect_skeleton_mesh.py | 11 ------ .../publish/validate_skeleton_rig_content.py | 36 +------------------ 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index 73b2103618..a22901357b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -12,7 +12,6 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): families = ["rig"] def process(self, instance): - skeleton_sets = instance.data.get("skeletonAnim_SET") skeleton_mesh_sets = instance.data.get("skeletonMesh_SET") if not skeleton_mesh_sets: self.log.debug( @@ -27,7 +26,6 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): instance.data["frameEnd"] = frame instance.data["skeleton_mesh"] = [] - instance.data["skeleton_rig"] = [] if skeleton_mesh_sets: instance.data["families"] += ["rig.fbx"] @@ -39,12 +37,3 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): self.log.debug( "Collected skeleton " f"mesh Set: {skeleton_mesh_content}") - - if skeleton_sets: - for skeleton_set in skeleton_sets: - skeleton_content = cmds.sets(skeleton_set, query=True) - self.log.debug( - "Collected animated " - f"skeleton data: {skeleton_content}") - if skeleton_content: - instance.data["skeleton_rig"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index a620c2f631..565295494a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -26,7 +26,7 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): accepted_controllers = ["transform", "locator"] def process(self, instance): - objectsets = ["skeletonAnim_SET", "skeletonMesh_SET"] + objectsets = ["skeletonMesh_SET"] missing = [ key for key in objectsets if key not in instance.data["rig_sets"] ] @@ -46,8 +46,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): skeleton_mesh_content = instance.data.get("skeleton_mesh", []) skeleton_mesh_content = cmds.ls(skeleton_mesh_content, long=True) - skeleton_anim_content = instance.data.get("skeleton_rig", []) - skeleton_anim_content = cmds.ls(skeleton_anim_content, long=True) # Validate members are inside the hierarchy from root node root_node = cmds.ls(set_members, assemblies=True) @@ -61,11 +59,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): if node not in hierarchy: invalid_hierarchy.append(node) invalid_geometry = self.validate_geometry(skeleton_mesh_content) - if skeleton_anim_content: - for node in skeleton_anim_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - invalid_controls = self.validate_controls(skeleton_anim_content) error = False if invalid_hierarchy: @@ -74,11 +67,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): "\n%s" % invalid_hierarchy) error = True - if invalid_controls: - self.log.error("Only transforms can be part of the " - "skeletonAnim_SET. \n%s" % invalid_controls) - error = True - if invalid_geometry: self.log.error("Only meshes can be part of the " "skeletonMesh_SET\n%s" % invalid_geometry) @@ -114,25 +102,3 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): invalid.append(shape) return invalid - - def validate_controls(self, set_members): - """Check if the controller set passes the validations - - Checks if all its set members are within the hierarchy of the root - Checks if the node types of the set members valid - - Args: - set_members: list of nodes of the skeleton_anim_set - hierarchy: list of nodes which reside under the root node - - Returns: - errors (list) - """ - - # Validate control types - invalid = [] - for node in set_members: - if cmds.nodeType(node) not in self.accepted_controllers: - invalid.append(node) - - return invalid From c5d54a522aad24886adda213c495ea657e9c1567 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:51:53 +0800 Subject: [PATCH 123/327] make sure skeleton_Anim set and skeleton_Mesh set inside the loaded rig_sets --- openpype/hosts/maya/api/lib.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 03a864a1db..b246a77512 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4111,6 +4111,8 @@ def create_rig_animation_instance( anim_skeleton = next((node for node in nodes if node.endswith("skeletonAnim_SET")), None) + skeleton_mesh = next((node for node in nodes if + node.endswith("skeletonMesh_SET")), None) # Find the roots amongst the loaded nodes roots = ( @@ -4147,6 +4149,8 @@ def create_rig_animation_instance( rig_sets = [output, controls] if anim_skeleton: rig_sets.append(anim_skeleton) + if skeleton_mesh: + rig_sets.append(skeleton_mesh) with maintained_selection(): cmds.select(rig_sets + roots, noExpand=True) create_context.create( From 1e7c544e902112fa9c0621fd629f438bf3cd0a36 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:57:30 +0800 Subject: [PATCH 124/327] hound --- .../hosts/maya/plugins/publish/validate_skeleton_rig_content.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 565295494a..9be7861309 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -46,7 +46,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): skeleton_mesh_content = instance.data.get("skeleton_mesh", []) skeleton_mesh_content = cmds.ls(skeleton_mesh_content, long=True) - # Validate members are inside the hierarchy from root node root_node = cmds.ls(set_members, assemblies=True) hierarchy = cmds.listRelatives(root_node, allDescendents=True, From 5a4ef31f4e06cec2ac49ec9486cf08dc95ceb7ad Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 23:03:10 +0800 Subject: [PATCH 125/327] remove animated_rig instance data in rig family --- .../maya/plugins/publish/validate_skeleton_rig_content.py | 2 -- .../publish/validate_skeleton_top_group_hierarchy.py | 7 ------- 2 files changed, 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 9be7861309..09c5bb5bdc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -11,7 +11,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): """Ensure skeleton rigs contains pipeline-critical content The rigs optionally contain at least two object sets: - "skeletonAnim_SET" - Set of only bone hierarchies "skeletonMesh_SET" - Set of the skinned meshes with bone hierarchies @@ -23,7 +22,6 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): families = ["rig.fbx"] accepted_output = ["mesh", "transform", "locator"] - accepted_controllers = ["transform", "locator"] def process(self, instance): objectsets = ["skeletonMesh_SET"] diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py index 1e0d856b4e..541efee9a9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -24,14 +24,7 @@ class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, def process(self, instance): invalid = [] - skeleton_data = instance.data.get(("animated_rigs"), []) skeleton_mesh_data = instance.data(("skeleton_mesh"), []) - if skeleton_data: - invalid = self.get_top_hierarchy(skeleton_data) - if invalid: - raise PublishValidationError( - "The skeletonAnim_SET includes the object which " - f"is not at the top hierarchy: {invalid}") if skeleton_mesh_data: invalid = self.get_top_hierarchy(skeleton_mesh_data) if invalid: From a7b99ac0b0e347c74be0d41a15837bc7b0f2b17d Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 23:19:06 +0800 Subject: [PATCH 126/327] make sure the namespace has been restored --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index e99e7d40bd..1647bbdcda 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -50,6 +50,8 @@ class ExtractFBXAnimation(publish.Extractor): fbx_exporter.export( new_out_set, path.replace("\\", "/")) + cmds.namespace(relativeNames=False) + representations = instance.data.setdefault("representations", []) representations.append({ 'name': 'fbx', From ea4ce1b8be7a721124445770a6169ab960145b3a Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 15:17:41 +0800 Subject: [PATCH 127/327] make sure the namespace has not been forcily restored --- .../maya/plugins/publish/extract_fbx_animation.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 1647bbdcda..d281e01779 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,13 +44,14 @@ class ExtractFBXAnimation(publish.Extractor): namespace = out_set_name.split(":")[0] new_out_set = out_set_name.replace( f"{namespace}:", "") - cmds.namespace(set=':' + namespace) cmds.namespace(relativeNames=True) with namespaced(":" + namespace, new=False) as namespace: - fbx_exporter.export( - new_out_set, path.replace("\\", "/")) - - cmds.namespace(relativeNames=False) + path = path.replace("\\", "/") + fbx_exporter.export(new_out_set, path) + original_relative_names = cmds.namespace( + query=True, relativeNames=True) + if original_relative_names: + cmds.namespace(relativeNames=original_relative_names) representations = instance.data.setdefault("representations", []) representations.append({ From 37cefd892c85218dc3614341866b5332c7384e10 Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 18:26:58 +0800 Subject: [PATCH 128/327] abstract namespaced functions for extract fbx animation and add fbx loaders in animatin family --- openpype/hosts/maya/api/fbx.py | 3 + openpype/hosts/maya/api/lib.py | 5 +- .../hosts/maya/plugins/create/create_rig.py | 2 +- .../maya/plugins/load/_load_animation.py | 102 ++++++++++++------ .../plugins/publish/collect_fbx_animation.py | 5 +- .../plugins/publish/collect_skeleton_mesh.py | 5 +- .../plugins/publish/extract_fbx_animation.py | 21 ++-- .../plugins/publish/extract_skeleton_mesh.py | 3 - .../validate_skeleton_rig_output_ids.py | 6 +- .../validate_skeleton_top_group_hierarchy.py | 14 ++- 10 files changed, 102 insertions(+), 64 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 5bd375362b..2dd4f5a73d 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -203,6 +203,9 @@ class FBXExtractor: path (str): Path to use for export. """ + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace("\\", "/") with maintained_selection(): cmds.select(members, r=True, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b246a77512..6019aec37c 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -922,7 +922,7 @@ def no_display_layers(nodes): @contextlib.contextmanager -def namespaced(namespace, new=True): +def namespaced(namespace, new=True, relative_names=None): """Work inside namespace during context Args: @@ -934,6 +934,7 @@ def namespaced(namespace, new=True): """ original = cmds.namespaceInfo(cur=True, absoluteName=True) + original_relative_names = cmds.namespace(query=True, relativeNames=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) @@ -943,6 +944,8 @@ def namespaced(namespace, new=True): yield namespace finally: cmds.namespace(set=original) + if relative_names is not None: + cmds.namespace(relativeNames=original_relative_names) @contextlib.contextmanager diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 22a94ed4fd..acd5c98f89 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -20,7 +20,7 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") - # TODO:change name (_controls_set -> _rigs_SET) + # TODO:change name (_controls_SET -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 6d67383909..2432184151 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -1,4 +1,46 @@ import openpype.hosts.maya.api.plugin +import maya.cmds as cmds + + +def _process_reference(file_url, name, namespace, options): + """_summary_ + + Args: + file_url (str): fileapth of the objects to be loaded + name (str): subset name + namespace (str): namespace + options (dict): dict of storing the param + + Returns: + list: list of object nodes + """ + from openpype.hosts.maya.api.lib import unique_namespace + # Get name from asset being loaded + # Assuming name is subset name from the animation, we split the number + # suffix from the name to ensure the namespace is unique + name = name.split("_")[0] + ext = file_url.split(".")[-1] + namespace = unique_namespace( + "{}_".format(name), + format="%03d", + suffix="_{}".format(ext) + ) + + attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] + + # no group shall be created + if not attach_to_root: + group_name = namespace + + nodes = cmds.file(file_url, + namespace=namespace, + sharedReferenceFile=False, + groupReference=attach_to_root, + groupName=group_name, + reference=True, + returnNewNodes=True) + return nodes class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -7,7 +49,7 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): families = ["animation", "camera", "pointcache"] - representations = ["abc", "fbx"] + representations = ["abc"] label = "Reference animation" order = -10 @@ -16,44 +58,42 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): - import maya.cmds as cmds - from openpype.hosts.maya.api.lib import unique_namespace - cmds.loadPlugin("AbcImport.mll", quiet=True) - # Prevent identical alembic nodes from being shared - # Create unique namespace for the cameras - - # Get name from asset being loaded - # Assuming name is subset name from the animation, we split the number - # suffix from the name to ensure the namespace is unique - name = name.split("_")[0] - namespace = unique_namespace( - "{}_".format(name), - format="%03d", - suffix="_abc" - ) - - attach_to_root = options.get("attach_to_root", True) - group_name = options["group_name"] - - # no group shall be created - if not attach_to_root: - group_name = namespace - # hero_001 (abc) # asset_counter{optional} path = self.filepath_from_context(context) file_url = self.prepare_root_value(path, context["project"]["name"]) - nodes = cmds.file(file_url, - namespace=namespace, - sharedReferenceFile=False, - groupReference=attach_to_root, - groupName=group_name, - reference=True, - returnNewNodes=True) + nodes = _process_reference(file_url, name, namespace, options) # load colorbleed ID attribute self[:] = nodes return nodes + + +class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): + """Loader to reference an Fbx files""" + + families = ["animation", + "camera"] + representations = ["fbx"] + + label = "Reference animation" + order = -10 + icon = "code-fork" + color = "orange" + + def process_reference(self, context, name, namespace, options): + + cmds.loadPlugin("fbx4maya.mll", quiet=True) + + path = self.filepath_from_context(context) + file_url = self.prepare_root_value(path, + context["project"]["name"]) + + nodes = _process_reference(file_url, name, namespace, options) + + self[:] = nodes + + return nodes diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 9347936e63..ee5ac741c8 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -29,7 +29,8 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin, for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) self.log.debug( - "Collected animated " - f"skeleton data: {skeleton_content}") + "Collected animated skeleton data: {}".format( + skeleton_content + )) if skeleton_content: instance.data["animated_skeleton"] += skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index a22901357b..9169e3dc28 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -35,5 +35,6 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): if skeleton_mesh_content: instance.data["skeleton_mesh"] += skeleton_mesh_content self.log.debug( - "Collected skeleton " - f"mesh Set: {skeleton_mesh_content}") + "Collected skeletonmesh Set: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index d281e01779..fbc1d5176c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -40,18 +40,15 @@ class ExtractFBXAnimation(publish.Extractor): fbx_exporter.set_options_from_instance(instance) out_set_name = next(out for out in out_set) - # temporarily disable namespace - namespace = out_set_name.split(":")[0] - new_out_set = out_set_name.replace( - f"{namespace}:", "") + # Export from the rig's namespace so that the exported + # FBX does not include the namespace but preserves the node + # names as existing in the rig workfile + namespace, relative_out_set = out_set_name.split(":", 1) cmds.namespace(relativeNames=True) - with namespaced(":" + namespace, new=False) as namespace: - path = path.replace("\\", "/") - fbx_exporter.export(new_out_set, path) - original_relative_names = cmds.namespace( - query=True, relativeNames=True) - if original_relative_names: - cmds.namespace(relativeNames=original_relative_names) + with namespaced( + ":" + namespace, + new=False, relative_names=True) as namespace: + fbx_exporter.export(relative_out_set, path) representations = instance.data.setdefault("representations", []) representations.append({ @@ -62,4 +59,4 @@ class ExtractFBXAnimation(publish.Extractor): }) self.log.debug( - "Extracted Fbx animation successful to: {0}".format(path)) + "Extracted Fbx animation to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py index 42cbb33013..cecdf282e2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -32,8 +32,6 @@ class ExtractSkeletonMesh(publish.Extractor, filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) - # The export requires forward slashes because we need - # to format it into a string in a mel expression fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("skeleton_mesh", []) @@ -43,7 +41,6 @@ class ExtractSkeletonMesh(publish.Extractor, fbx_exporter.set_options_from_instance(instance) # Export - path = path.replace("\\", "/") fbx_exporter.export(out_set, path) representations = instance.data.setdefault("representations", []) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py index 0d1e702749..735ca27b39 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py @@ -68,9 +68,7 @@ class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): if shapes: instance_nodes.extend(shapes) - scene_nodes = cmds.ls(type="transform", long=True) - scene_nodes += cmds.ls(type="mesh", long=True) - scene_nodes = set(scene_nodes) - set(instance_nodes) + scene_nodes = cmds.ls(type=("transform", "mesh"), long=True) scene_nodes_by_basename = defaultdict(list) for node in scene_nodes: @@ -109,7 +107,7 @@ class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): for instance_node, matches in invalid_matches.items(): ids = set(get_id(node) for node in matches) - # If there are multiple scene ids matched, and error needs to be + # If there are multiple scene ids matched, an error needs to be # raised for manual correction. if len(ids) > 1: multiple_ids_match.append({"node": instance_node, diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py index 541efee9a9..553618aa50 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -24,19 +24,17 @@ class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, def process(self, instance): invalid = [] - skeleton_mesh_data = instance.data(("skeleton_mesh"), []) + skeleton_mesh_data = instance.data("skeleton_mesh", []) if skeleton_mesh_data: invalid = self.get_top_hierarchy(skeleton_mesh_data) if invalid: raise PublishValidationError( "The skeletonMesh_SET includes the object which " - f"is not at the top hierarchy: {invalid}") + "is not at the top hierarchy: {}".format(invalid)) def get_top_hierarchy(self, targets): - non_top_hierarchy_list = [] - for target in targets: - long_names = cmds.ls(target, long=True) - for name in long_names: - if len(name.split["|"]) > 2: - non_top_hierarchy_list.append(name) + targets = cmds.ls(targets, long=True) # ensure long names + non_top_hierarchy_list = [ + target for target in targets if target.count("|") > 2 + ] return non_top_hierarchy_list From 08f47c77fd25cee3000bf4e86e21318586f87c43 Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 18:29:58 +0800 Subject: [PATCH 129/327] hound --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index fbc1d5176c..115ba39986 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -45,9 +45,7 @@ class ExtractFBXAnimation(publish.Extractor): # names as existing in the rig workfile namespace, relative_out_set = out_set_name.split(":", 1) cmds.namespace(relativeNames=True) - with namespaced( - ":" + namespace, - new=False, relative_names=True) as namespace: + with namespaced(":" + namespace,new=False, relative_names=True) as namespace: # noqa fbx_exporter.export(relative_out_set, path) representations = instance.data.setdefault("representations", []) From bafd908483136c0e6829e22d2048484bbc161908 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Sep 2023 15:13:17 +0200 Subject: [PATCH 130/327] broken settings fixed by reverting changes form previous PR https://github.com/ynput/OpenPype/pull/5409 --- .../hosts/nuke/plugins/publish/collect_nuke_instance_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index b0f69e8ab8..449a1cc935 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -2,7 +2,7 @@ import nuke import pyblish.api -class CollectNukeInstanceData(pyblish.api.InstancePlugin): +class CollectInstanceData(pyblish.api.InstancePlugin): """Collect Nuke instance data """ From d8715d59d0d9b095cbd7ba84d72e079ba2e12a4d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 16:57:05 +0300 Subject: [PATCH 131/327] allow values other than paths --- openpype/hosts/houdini/api/lib.py | 62 ++++++++++--------- openpype/pipeline/context_tools.py | 55 ++++++++++++---- .../defaults/project_settings/houdini.json | 10 ++- .../schemas/schema_houdini_general.json | 22 ++++++- .../houdini/server/settings/general.py | 6 +- 5 files changed, 106 insertions(+), 49 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index f8d17eef07..637339f822 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -768,45 +768,49 @@ def update_houdini_vars_context(): if houdini_vars_settings["enabled"]: houdini_vars = houdini_vars_settings["houdini_vars"] - # Remap AYON settings structure to OpenPype settings structure - # It allows me to use the same logic for both AYON and OpenPype - if isinstance(houdini_vars, list): - items = {} - for item in houdini_vars: - items.update({item["var"]: item["path"]}) + # No vars specified - nothing to do + if not houdini_vars: + return - houdini_vars = items + # Get Template data + template_data = get_current_context_template_data() + + # Set Houdini Vars + for item in houdini_vars: + + # For consistency reasons we always force all vars to be uppercase + item["var"] = item["var"].upper() - for var, path in houdini_vars.items(): # get and resolve job path template - path = StringTemplate.format_template( - path, - get_current_context_template_data() + item_value = StringTemplate.format_template( + item["value"], + template_data ) - path = path.replace("\\", "/") - if var == "JOB" and path == "": + if item["is_path"]: + item_value = item_value.replace("\\", "/") + try: + os.makedirs(item_value) + except OSError as e: + if e.errno != errno.EEXIST: + print( + " - Failed to create ${} dir. Maybe due to " + "insufficient permissions.".format(item["var"]) + ) + + if item["var"] == "JOB" and item_value == "": # sync $JOB to $HIP if $JOB is empty - path = os.environ["HIP"] + item_value = os.environ["HIP"] - current_path = hou.hscript("echo -n `${}`".format(var))[0] + current_value = hou.hscript("echo -n `${}`".format(item["var"]))[0] # sync both environment variables. # because houdini doesn't do that by default # on opening new files - os.environ[var] = current_path + os.environ[item["var"]] = current_value - if current_path != path: - hou.hscript("set {}={}".format(var, path)) - os.environ[var] = path + if current_value != item_value: + hou.hscript("set {}={}".format(item["var"], item_value)) + os.environ[item["var"]] = item_value - try: - os.makedirs(path) - except OSError as e: - if e.errno != errno.EEXIST: - print( - " - Failed to create {} dir. Maybe due to " - "insufficient permissions.".format(var) - ) - - print(" - Updated ${} to {}".format(var, path)) + print(" - Updated ${} to {}".format(item["var"], item_value)) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 13b14f1296..f98132e270 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -667,17 +667,30 @@ def get_current_context_template_data(): """Template data for template fill from current context Returns: - Dict[str, str] of the following tokens and their values - - app - - user - - asset - - parent - - hierarchy - - folder[name] - - root[work, ...] - - studio[code, name] - - project[code, name] - - task[type, name, short] + Dict[str, Any] of the following tokens and their values + Supported Tokens: + - Regular Tokens + - app + - user + - asset + - parent + - hierarchy + - folder[name] + - root[work, ...] + - studio[code, name] + - project[code, name] + - task[type, name, short] + + - Context Specific Tokens + - assetData[frameStart] + - assetData[frameEnd] + - assetData[handleStart] + - assetData[handleEnd] + - assetData[frameStartHandle] + - assetData[frameEndHandle] + - assetData[resolutionHeight] + - assetData[resolutionWidth] + """ # pre-prepare get_template_data args @@ -692,10 +705,28 @@ def get_current_context_template_data(): task_name = current_context["task_name"] host_name = get_current_host_name() - # get template data + # get regular template data template_data = get_template_data( project_doc, asset_doc, task_name, host_name ) template_data["root"] = anatomy.roots + + # get context specific vars + asset_data = asset_doc["data"].copy() + + # compute `frameStartHandle` and `frameEndHandle` + if "frameStart" in asset_data and "handleStart" in asset_data: + asset_data["frameStartHandle"] = ( + asset_data["frameStart"] - asset_data["handleStart"] + ) + + if "frameEnd" in asset_data and "handleEnd" in asset_data: + asset_data["frameEndHandle"] = ( + asset_data["frameEnd"] + asset_data["handleEnd"] + ) + + # add assetData + template_data["assetData"] = asset_data + return template_data diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index b2fcb708cf..3c43e7ae29 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -2,9 +2,13 @@ "general": { "update_houdini_var_context": { "enabled": true, - "houdini_vars":{ - "JOB": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" - } + "houdini_vars":[ + { + "var": "JOB", + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", + "is_path": true + } + ] } }, "imageio": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index 127382f4bc..2989d5c5b9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -18,13 +18,29 @@ "label": "Enabled" }, { - "type": "dict-modifiable", + "type": "list", "key": "houdini_vars", "label": "Houdini Vars", "collapsible": false, "object_type": { - "type": "path", - "multiplatform": false + "type": "dict", + "children": [ + { + "type": "text", + "key": "var", + "label": "Var" + }, + { + "type": "text", + "key": "value", + "label": "Value" + }, + { + "type": "boolean", + "key": "is_path", + "label": "isPath" + } + ] } } ] diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 42a071a688..468c571993 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -5,7 +5,8 @@ from ayon_server.settings import BaseSettingsModel class HoudiniVarModel(BaseSettingsModel): _layout = "expanded" var: str = Field("", title="Var") - path: str = Field(default_factory="", title="Path") + value: str = Field("", title="Value") + is_path: bool = Field(False, title="isPath") class UpdateHoudiniVarcontextModel(BaseSettingsModel): @@ -30,7 +31,8 @@ DEFAULT_GENERAL_SETTINGS = { "houdini_vars": [ { "var": "JOB", - "path": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}" # noqa + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa + "is_path": True } ] } From b93da3bd3ddacfcc3770ee44032f51ec9184b6a4 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 16:59:07 +0300 Subject: [PATCH 132/327] resolve hound --- openpype/pipeline/context_tools.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index f98132e270..13630ae7ca 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -717,14 +717,12 @@ def get_current_context_template_data(): # compute `frameStartHandle` and `frameEndHandle` if "frameStart" in asset_data and "handleStart" in asset_data: - asset_data["frameStartHandle"] = ( - asset_data["frameStart"] - asset_data["handleStart"] - ) + asset_data["frameStartHandle"] = \ + asset_data["frameStart"] - asset_data["handleStart"] if "frameEnd" in asset_data and "handleEnd" in asset_data: - asset_data["frameEndHandle"] = ( - asset_data["frameEnd"] + asset_data["handleEnd"] - ) + asset_data["frameEndHandle"] = \ + asset_data["frameEnd"] + asset_data["handleEnd"] # add assetData template_data["assetData"] = asset_data From 846bd0fd590688ceb4640b00f8f0f3af2ce6fa65 Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 22:12:02 +0800 Subject: [PATCH 133/327] make sure there is a check in relative names in namespace before yield function & add docstring --- openpype/hosts/maya/api/lib.py | 3 ++- openpype/hosts/maya/plugins/load/_load_animation.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 6019aec37c..dc881879ac 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -938,7 +938,8 @@ def namespaced(namespace, new=True, relative_names=None): if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) - + if relative_names is not None: + cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 2432184151..0781735bc4 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -3,7 +3,7 @@ import maya.cmds as cmds def _process_reference(file_url, name, namespace, options): - """_summary_ + """Load files by referencing scene in Maya. Args: file_url (str): fileapth of the objects to be loaded From a4d55b420b53e475e1ba05c45504b87c65bdbe46 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 17:25:04 +0300 Subject: [PATCH 134/327] update docs and rename `isPath` to `is Dir Path` --- openpype/hosts/houdini/api/lib.py | 2 +- .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_general.json | 4 +-- .../houdini/server/settings/general.py | 4 +-- website/docs/admin_hosts_houdini.md | 24 ++++++++---------- .../update-houdini-vars-context-change.png | Bin 0 -> 18456 bytes .../houdini/update-job-context-change.png | Bin 8068 -> 0 bytes 7 files changed, 17 insertions(+), 19 deletions(-) create mode 100644 website/docs/assets/houdini/update-houdini-vars-context-change.png delete mode 100644 website/docs/assets/houdini/update-job-context-change.png diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 637339f822..291817bbe9 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -787,7 +787,7 @@ def update_houdini_vars_context(): template_data ) - if item["is_path"]: + if item["is_dir_path"]: item_value = item_value.replace("\\", "/") try: os.makedirs(item_value) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 3c43e7ae29..111ed2b24d 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -6,7 +6,7 @@ { "var": "JOB", "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", - "is_path": true + "is_dir_path": true } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index 2989d5c5b9..3160e657bf 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -37,8 +37,8 @@ }, { "type": "boolean", - "key": "is_path", - "label": "isPath" + "key": "is_dir_path", + "label": "is Dir Path" } ] } diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 468c571993..7b3b95f978 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -6,7 +6,7 @@ class HoudiniVarModel(BaseSettingsModel): _layout = "expanded" var: str = Field("", title="Var") value: str = Field("", title="Value") - is_path: bool = Field(False, title="isPath") + is_dir_path: bool = Field(False, title="is Dir Path") class UpdateHoudiniVarcontextModel(BaseSettingsModel): @@ -32,7 +32,7 @@ DEFAULT_GENERAL_SETTINGS = { { "var": "JOB", "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa - "is_path": True + "is_dir_path": True } ] } diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index ea7991530b..749ca43fe2 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -4,26 +4,24 @@ title: Houdini sidebar_label: Houdini --- ## General Settings -### JOB Path +### Houdini Vars + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task. + +Using template keys is supported but formatting keys capitalization variants is not, e.g. `{Asset}` and `{ASSET}` won't work -The Houdini `$JOB` path can be customized through project settings with a (dynamic) path that will be updated on context changes, e.g. when switching to another asset or task. :::note -If the folder does not exist on the context change it will be created by this feature so that `$JOB` will always try to point to an existing folder. +If `is Dir Path` toggle is activated, Openpype will consider the given value is a path of a folder. + +If the folder does not exist on the context change it will be created by this feature so that the path will always try to point to an existing folder. ::: -Disabling this feature will leave `$JOB` var unmanaged and thus no context update changes will occur. +Disabling `Update Houdini vars on context change` feature will leave all Houdini vars unmanaged and thus no context update changes will occur. -JOB Path can be: -- Arbitrary path -- Openpype template path - > This allows dynamic values for assets or shots.
- > Using template keys is supported but formatting keys capitalization variants is not, - > e.g. `{Asset}` and `{ASSET}` won't work -- Empty - > In this case, JOB will be synced to HIP +> If `$JOB` is present in the Houdini var list and has an empty value, OpenPype will set its value to `$HIP` -![update job on context change](assets/houdini/update-job-context-change.png) +![update-houdini-vars-context-change](assets/houdini/update-houdini-vars-context-change.png) diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png new file mode 100644 index 0000000000000000000000000000000000000000..77c67a620dd50923bafbba61f4f5e0f9236a3f27 GIT binary patch literal 18456 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 From b45e8c3131f6c136970afccf7c6ef4b3db251e93 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 17:37:29 +0300 Subject: [PATCH 135/327] update photo --- .../update-houdini-vars-context-change.png | Bin 18456 -> 18904 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png index 77c67a620dd50923bafbba61f4f5e0f9236a3f27..76fb7233214c685cfe632d05e937535280c723f0 100644 GIT binary patch literal 18904 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 literal 18456 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 Date: Fri, 29 Sep 2023 17:44:36 +0300 Subject: [PATCH 136/327] avoid using nested if --- openpype/hosts/houdini/api/lib.py | 78 ++++++++++++++++--------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 291817bbe9..67755c1a72 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -765,52 +765,54 @@ def update_houdini_vars_context(): houdini_vars_settings = \ project_settings["houdini"]["general"]["update_houdini_var_context"] - if houdini_vars_settings["enabled"]: - houdini_vars = houdini_vars_settings["houdini_vars"] + if not houdini_vars_settings["enabled"]: + return - # No vars specified - nothing to do - if not houdini_vars: - return + houdini_vars = houdini_vars_settings["houdini_vars"] - # Get Template data - template_data = get_current_context_template_data() + # No vars specified - nothing to do + if not houdini_vars: + return - # Set Houdini Vars - for item in houdini_vars: + # Get Template data + template_data = get_current_context_template_data() - # For consistency reasons we always force all vars to be uppercase - item["var"] = item["var"].upper() + # Set Houdini Vars + for item in houdini_vars: - # get and resolve job path template - item_value = StringTemplate.format_template( - item["value"], - template_data - ) + # For consistency reasons we always force all vars to be uppercase + item["var"] = item["var"].upper() - if item["is_dir_path"]: - item_value = item_value.replace("\\", "/") - try: - os.makedirs(item_value) - except OSError as e: - if e.errno != errno.EEXIST: - print( - " - Failed to create ${} dir. Maybe due to " - "insufficient permissions.".format(item["var"]) - ) + # get and resolve template in value + item_value = StringTemplate.format_template( + item["value"], + template_data + ) - if item["var"] == "JOB" and item_value == "": - # sync $JOB to $HIP if $JOB is empty - item_value = os.environ["HIP"] + if item["is_dir_path"]: + item_value = item_value.replace("\\", "/") + try: + os.makedirs(item_value) + except OSError as e: + if e.errno != errno.EEXIST: + print( + " - Failed to create ${} dir. Maybe due to " + "insufficient permissions.".format(item["var"]) + ) - current_value = hou.hscript("echo -n `${}`".format(item["var"]))[0] + if item["var"] == "JOB" and item_value == "": + # sync $JOB to $HIP if $JOB is empty + item_value = os.environ["HIP"] - # sync both environment variables. - # because houdini doesn't do that by default - # on opening new files - os.environ[item["var"]] = current_value + current_value = hou.hscript("echo -n `${}`".format(item["var"]))[0] - if current_value != item_value: - hou.hscript("set {}={}".format(item["var"], item_value)) - os.environ[item["var"]] = item_value + # sync both environment variables. + # because houdini doesn't do that by default + # on opening new files + os.environ[item["var"]] = current_value - print(" - Updated ${} to {}".format(item["var"], item_value)) + if current_value != item_value: + hou.hscript("set {}={}".format(item["var"], item_value)) + os.environ[item["var"]] = item_value + + print(" - Updated ${} to {}".format(item["var"], item_value)) From f287144616a5341c6651cb5af5b49416af3b4e30 Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 23:28:39 +0800 Subject: [PATCH 137/327] make sure validators for skeleton mesh are in rig.fbx family --- .../maya/plugins/publish/validate_skeleton_rig_content.py | 2 +- .../plugins/publish/validate_skeleton_top_group_hierarchy.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py index 09c5bb5bdc..8b6cc74332 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py @@ -21,7 +21,7 @@ class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): hosts = ["maya"] families = ["rig.fbx"] - accepted_output = ["mesh", "transform", "locator"] + accepted_output = {"mesh", "transform", "locator"} def process(self, instance): objectsets = ["skeletonMesh_SET"] diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py index 553618aa50..1dbe1c454c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -19,8 +19,8 @@ class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, """ order = ValidateContentsOrder + 0.05 - label = "Top Group Hierarchy" - families = ["rig"] + label = "Skeleton Rig Top Group Hierarchy" + families = ["rig.fbx"] def process(self, instance): invalid = [] From a66edaf1d051be4c6be2cfe189fe7a5912296968 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Sep 2023 17:33:28 +0200 Subject: [PATCH 138/327] Maya: implement matchmove publishing (#5445) * OP-6360 - allow export of multiple cameras as alembic * OP-6360 - make validation of camera count optional * OP-6360 - make ValidatorCameraContents optional This validator checks number of cameras, without optionality publish wouldn't be possible. * OP-6360 - allow extraction of multiple cameras to .ma * OP-6360 - update defaults for Ayon Changes to Ayon settings should also bump up version of addon. * OP-6360 - new matchmove creator This family should be for more complex sets (eg. multiple cameras, with geometry, planes etc. * OP-6360 - updated camera extractors Added matchmove family to extract multiple cameras. Single camera is protected by required validator. * OP-6360 - added matchmove to reference loader * Revert "OP-6360 - make ValidatorCameraContents optional" This reverts commit 4096e81f785b1299b54b1e485eb672403fb89a66. * Revert "OP-6360 - update defaults for Ayon" This reverts commit 4391b25cfc93fbb783146a726c6097477146c467. * OP-6360 - performance update Number of cameras might be quite large, set operations will be faster than loop. * Revert "OP-6360 - make validation of camera count optional" This reverts commit ee3d91a4cbec607b0f8cc9d47382684eba88d6d0. * OP-6360 - explicitly cast to list for Maya functions cmds.ls doesn't like sets in some older versions of Maya apparently. Sets are used here for performance reason, so explicitly cast them to list to make Maya happy. * OP-6360 - added documentation about matchmove family * OP-6360 - copy input planes * OP-6360 - expose Settings to keep Image planes Previous implementation didn't export Image planes in Maya file, to keep behavior backward compatible new Setting was added and set to False. * OP-6360 - make both camera extractors optional In Settings Alembic extractor was visible as optional even if code didn't follow that. * OP-6360 - used long name * OP-6360 - fix wrong variable * Update openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py Co-authored-by: Roy Nieterau * OP-6360 - removed shortening of varible * OP-6360 - Hound * OP-6360 - fix wrong key * Update openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py Co-authored-by: Toke Jepsen * Update openpype/hosts/maya/api/lib.py Co-authored-by: Toke Jepsen * Update openpype/hosts/maya/plugins/publish/extract_camera_alembic.py Co-authored-by: Toke Jepsen * OP-6360 - fix wrong variable * OP-6360 - added reattaching method Image planes were attached wrong, added method to reattach them properly. * Revert "Update openpype/hosts/maya/api/lib.py" This reverts commit 4f40ad613946903e8c51b2720ac52756e701f8b8. * OP-6360 - exported baked camera should be deleted Forgotten commenting just for development. * OP-6360 - updated docstring * OP-6360 - remove scale keys Currently parentConstraint from old camera to new one doesn't work for keyed scale attributes. To key scale attributes doesn't make much sense so as a workaround, keys for scale attributes are checked AND if they are diferent from defaults (1.0) publish fails (as artist might want to actually key scale). If all scale keys are defaults, they are temporarily removed, cameras are parent constrained, exported and old camera returned to original state. * OP-6360 - cleaned up resetting of scale keys Batch calls used instead of one by one. Cleaned up a return type as key value is no necessary as we are not setting it, just key. * OP-6360 - removed unnecessary logging * OP-6360 - reattach image plane to original camera Image plane must be reattached before baked camera(s) are deleted. * OP-6360 - added context manager to keep image planes attached to original camera Without this image planes would disappear after removal of baked cameras. * OP-6360 - refactored contextmanager * OP-6360 - renamed flag Input connections are not copied anymore as they might be dangerous. It is possible to epxlicitly attach only image planes instead. * OP-6360 - removed copyInputConnections Copying input connections might be dangerous (rig etc.), it is possible to explicitly attach only image planes. * OP-6360 - updated plugin labels * Update openpype/hosts/maya/plugins/create/create_matchmove.py Co-authored-by: Roy Nieterau * OP-6360 - fixed formatting --------- Co-authored-by: Roy Nieterau Co-authored-by: Toke Jepsen --- openpype/hosts/maya/api/lib.py | 2 +- .../maya/plugins/create/create_matchmove.py | 32 ++++ .../hosts/maya/plugins/load/load_reference.py | 3 +- .../plugins/publish/extract_camera_alembic.py | 22 ++- .../publish/extract_camera_mayaScene.py | 142 ++++++++++++++---- .../defaults/project_settings/maya.json | 6 + .../schemas/schema_maya_publish.json | 29 ++++ website/docs/artist_publish.md | 70 ++++----- 8 files changed, 229 insertions(+), 77 deletions(-) create mode 100644 openpype/hosts/maya/plugins/create/create_matchmove.py diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 40b3419e73..a197e5b592 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2571,7 +2571,7 @@ def bake_to_world_space(nodes, new_name = "{0}_baked".format(short_name) new_node = cmds.duplicate(node, name=new_name, - renameChildren=True)[0] + renameChildren=True)[0] # noqa # Connect all attributes on the node except for transform # attributes diff --git a/openpype/hosts/maya/plugins/create/create_matchmove.py b/openpype/hosts/maya/plugins/create/create_matchmove.py new file mode 100644 index 0000000000..e64eb6a471 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_matchmove.py @@ -0,0 +1,32 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import BoolDef + + +class CreateMatchmove(plugin.MayaCreator): + """Instance for more complex setup of cameras. + + Might contain multiple cameras, geometries etc. + + It is expected to be extracted into .abc or .ma + """ + + identifier = "io.openpype.creators.maya.matchmove" + label = "Matchmove" + family = "matchmove" + icon = "video-camera" + + def get_instance_attr_defs(self): + + defs = lib.collect_animation_defs() + + defs.extend([ + BoolDef("bakeToWorldSpace", + label="Bake Cameras to World-Space", + tooltip="Bake Cameras to World-Space", + default=True), + ]) + + return defs diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 61f337f501..4b704fa706 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -101,7 +101,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "camerarig", "staticMesh", "skeletalMesh", - "mvLook"] + "mvLook", + "matchmove"] representations = ["ma", "abc", "fbx", "mb"] diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index 4ec1399df4..43803743bc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -6,17 +6,21 @@ from openpype.pipeline import publish from openpype.hosts.maya.api import lib -class ExtractCameraAlembic(publish.Extractor): +class ExtractCameraAlembic(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Alembic. - The cameras gets baked to world space by default. Only when the instance's + The camera gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + """ - label = "Camera (Alembic)" + label = "Extract Camera (Alembic)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] bake_attributes = [] def process(self, instance): @@ -35,10 +39,11 @@ class ExtractCameraAlembic(publish.Extractor): # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] # Define extract output file path dir_path = self.staging_dir(instance) + if not os.path.exists(dir_path): + os.makedirs(dir_path) filename = "{0}.abc".format(instance.name) path = os.path.join(dir_path, filename) @@ -64,9 +69,10 @@ class ExtractCameraAlembic(publish.Extractor): # if baked, drop the camera hierarchy to maintain # clean output and backwards compatibility - camera_root = cmds.listRelatives( - camera, parent=True, fullPath=True)[0] - job_str += ' -root {0}'.format(camera_root) + camera_roots = cmds.listRelatives( + cameras, parent=True, fullPath=True) + for camera_root in camera_roots: + job_str += ' -root {0}'.format(camera_root) for member in members: descendants = cmds.listRelatives(member, diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index a50a8f0dfa..38cf00bbdd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -2,11 +2,15 @@ """Extract camera as Maya Scene.""" import os import itertools +import contextlib from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib +from openpype.lib import ( + BoolDef +) def massage_ma_file(path): @@ -78,7 +82,8 @@ def unlock(plug): cmds.disconnectAttr(source, destination) -class ExtractCameraMayaScene(publish.Extractor): +class ExtractCameraMayaScene(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* @@ -88,17 +93,22 @@ class ExtractCameraMayaScene(publish.Extractor): The cameras gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + Note: The extracted Maya ascii file gets "massaged" removing the uuid values so they are valid for older versions of Fusion (e.g. 6.4) """ - label = "Camera (Maya Scene)" + label = "Extract Camera (Maya Scene)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] scene_type = "ma" + keep_image_planes = True + def process(self, instance): """Plugin entry point.""" # get settings @@ -131,15 +141,15 @@ class ExtractCameraMayaScene(publish.Extractor): "bake to world space is ignored...") # get cameras - members = cmds.ls(instance.data['setMembers'], leaf=True, shapes=True, - long=True, dag=True) - cameras = cmds.ls(members, leaf=True, shapes=True, long=True, - dag=True, type="camera") + members = set(cmds.ls(instance.data['setMembers'], leaf=True, + shapes=True, long=True, dag=True)) + cameras = set(cmds.ls(members, leaf=True, shapes=True, long=True, + dag=True, type="camera")) # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] - transform = cmds.listRelatives(camera, parent=True, fullPath=True) + transforms = cmds.listRelatives(list(cameras), + parent=True, fullPath=True) # Define extract output file path dir_path = self.staging_dir(instance) @@ -151,23 +161,21 @@ class ExtractCameraMayaScene(publish.Extractor): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: - self.log.debug( - "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( - transform, + transforms, frame_range=[start, end], step=step ) - baked_camera_shapes = cmds.ls(baked, - type="camera", - dag=True, - shapes=True, - long=True) + baked_camera_shapes = set(cmds.ls(baked, + type="camera", + dag=True, + shapes=True, + long=True)) - members = members + baked_camera_shapes - members.remove(camera) + members.update(baked_camera_shapes) + members.difference_update(cameras) else: - baked_camera_shapes = cmds.ls(cameras, + baked_camera_shapes = cmds.ls(list(cameras), type="camera", dag=True, shapes=True, @@ -186,19 +194,28 @@ class ExtractCameraMayaScene(publish.Extractor): unlock(plug) cmds.setAttr(plug, value) - self.log.debug("Performing extraction..") - cmds.select(cmds.ls(members, dag=True, - shapes=True, long=True), noExpand=True) - cmds.file(path, - force=True, - typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 - exportSelected=True, - preserveReferences=False, - constructionHistory=False, - channels=True, # allow animation - constraints=False, - shader=False, - expressions=False) + attr_values = self.get_attr_values_from_data( + instance.data) + keep_image_planes = attr_values.get("keep_image_planes") + + with transfer_image_planes(sorted(cameras), + sorted(baked_camera_shapes), + keep_image_planes): + + self.log.info("Performing extraction..") + cmds.select(cmds.ls(list(members), dag=True, + shapes=True, long=True), + noExpand=True) + cmds.file(path, + force=True, + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 + exportSelected=True, + preserveReferences=False, + constructionHistory=False, + channels=True, # allow animation + constraints=False, + shader=False, + expressions=False) # Delete the baked hierarchy if bake_to_worldspace: @@ -219,3 +236,62 @@ class ExtractCameraMayaScene(publish.Extractor): self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) + + @classmethod + def get_attribute_defs(cls): + defs = super(ExtractCameraMayaScene, cls).get_attribute_defs() + + defs.extend([ + BoolDef("keep_image_planes", + label="Keep Image Planes", + tooltip="Preserving connected image planes on camera", + default=cls.keep_image_planes), + + ]) + + return defs + + +@contextlib.contextmanager +def transfer_image_planes(source_cameras, target_cameras, + keep_input_connections): + """Reattaches image planes to baked or original cameras. + + Baked cameras are duplicates of original ones. + This attaches it to duplicated camera properly and after + export it reattaches it back to original to keep image plane in workfile. + """ + originals = {} + try: + for source_camera, target_camera in zip(source_cameras, + target_cameras): + image_planes = cmds.listConnections(source_camera, + type="imagePlane") or [] + + # Split of the parent path they are attached - we want + # the image plane node name. + # TODO: Does this still mean the image plane name is unique? + image_planes = [x.split("->", 1)[1] for x in image_planes] + + if not image_planes: + continue + + originals[source_camera] = [] + for image_plane in image_planes: + if keep_input_connections: + if source_camera == target_camera: + continue + _attach_image_plane(target_camera, image_plane) + else: # explicitly dettaching image planes + cmds.imagePlane(image_plane, edit=True, detach=True) + originals[source_camera].append(image_plane) + yield + finally: + for camera, image_planes in originals.items(): + for image_plane in image_planes: + _attach_image_plane(camera, image_plane) + + +def _attach_image_plane(camera, image_plane): + cmds.imagePlane(image_plane, edit=True, detach=True) + cmds.imagePlane(image_plane, edit=True, camera=camera) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 38f14ec022..83ca6fecef 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1338,6 +1338,12 @@ "active": true, "bake_attributes": [] }, + "ExtractCameraMayaScene": { + "enabled": true, + "optional": true, + "active": true, + "keep_image_planes": false + }, "ExtractGLB": { "enabled": true, "active": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index b115ee3faa..13c00ff183 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -978,6 +978,35 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractCameraMayaScene", + "label": "Extract camera to Maya scene", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "keep_image_planes", + "label": "Export Image planes" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/website/docs/artist_publish.md b/website/docs/artist_publish.md index 321eb5c56a..b1be2e629e 100644 --- a/website/docs/artist_publish.md +++ b/website/docs/artist_publish.md @@ -33,39 +33,41 @@ The Instances are categorized into ‘families’ based on what type of data the Following family definitions and requirements are OpenPype defaults and what we consider good industry practice, but most of the requirements can be easily altered to suit the studio or project needs. Here's a list of supported families -| Family | Comment | Example Subsets | -| ----------------------- | ------------------------------------------------ | ------------------------- | -| [Model](#model) | Cleaned geo without materials | main, proxy, broken | -| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | -| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | -| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | -| [Layout](#layout) | Simple representation of the environment | main, | -| [Setdress](#setdress) | Environment containing only referenced assets | main, | -| [Camera](#camera) | May contain trackers or proxy geo | main, tracked, anim | -| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | -| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | -| MayaAscii | Maya publishes that don't fit other categories | | -| [Render](#render) | Rendered frames from CG or Comp | | -| RenderSetup | Scene render settings, AOVs and layers | | -| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | -| Write | Nuke write nodes for rendering | | -| Image | Any non-plate image to be used by artists | Reference, ConceptArt | -| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | -| Review | Reviewable video or image. | | -| Matchmove | Matchmoved camera, potentially with geometry | main | -| Workfile | Backup of the workfile with all its content | uses the task name | -| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | -| Yeticache | Cached out yeti fur setup | | -| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | -| VrayProxy | Vray proxy geometry for rendering | | -| VrayScene | Vray full scene export | | -| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | -| LUT | | | -| Nukenodes | | | -| Gizmo | | | -| Nukenodes | | | -| Harmony.template | | | -| Harmony.palette | | | +| Family | Comment | Example Subsets | +|-------------------------|-------------------------------------------------------| ------------------------- | +| [Model](#model) | Cleaned geo without materials | main, proxy, broken | +| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | +| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | +| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | +| [Layout](#layout) | Simple representation of the environment | main, | +| [Setdress](#setdress) | Environment containing only referenced assets | main, | +| [Camera](#camera) | May contain trackers or proxy geo, only single camera | main, tracked, anim | +| | expected. | | +| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | +| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | +| MayaAscii | Maya publishes that don't fit other categories | | +| [Render](#render) | Rendered frames from CG or Comp | | +| RenderSetup | Scene render settings, AOVs and layers | | +| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | +| Write | Nuke write nodes for rendering | | +| Image | Any non-plate image to be used by artists | Reference, ConceptArt | +| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | +| Review | Reviewable video or image. | | +| Matchmove | Matchmoved camera, potentially with geometry, allows | main | +| | multiple cameras even with planes. | | +| Workfile | Backup of the workfile with all its content | uses the task name | +| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | +| Yeticache | Cached out yeti fur setup | | +| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | +| VrayProxy | Vray proxy geometry for rendering | | +| VrayScene | Vray full scene export | | +| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | +| LUT | | | +| Nukenodes | | | +| Gizmo | | | +| Nukenodes | | | +| Harmony.template | | | +| Harmony.palette | | | @@ -161,7 +163,7 @@ Example Representations: ### Animation Published result of an animation created with a rig. Animation can be extracted -as animation curves, cached out geometry or even fully animated rig with all the controllers. +as animation curves, cached out geometry or even fully animated rig with all the controllers. Animation cache is usually defined by a rigger in the rig file of a character or by FX TD in the effects rig, to ensure consistency of outputs. From 82b2bd4b4540c435a76e1aa3bcc911296c887c74 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 19:32:08 +0300 Subject: [PATCH 139/327] update labels and add settings tips --- openpype/hosts/houdini/api/lib.py | 2 +- .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_general.json | 8 ++++++-- .../houdini/server/settings/general.py | 10 ++++++++-- .../update-houdini-vars-context-change.png | Bin 18904 -> 23727 bytes 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 67755c1a72..ce89ffe606 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -789,7 +789,7 @@ def update_houdini_vars_context(): template_data ) - if item["is_dir_path"]: + if item["is_directory"]: item_value = item_value.replace("\\", "/") try: os.makedirs(item_value) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 111ed2b24d..4f57ee52c6 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -6,7 +6,7 @@ { "var": "JOB", "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", - "is_dir_path": true + "is_directory": true } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index 3160e657bf..c1e2cae8f0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -17,6 +17,10 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "label", + "label": "Houdini Vars.
If a value is treated as a directory on update it will be ensured the folder exists" + }, { "type": "list", "key": "houdini_vars", @@ -37,8 +41,8 @@ }, { "type": "boolean", - "key": "is_dir_path", - "label": "is Dir Path" + "key": "is_directory", + "label": "Treat as directory" } ] } diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 7b3b95f978..0109eec63d 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -6,10 +6,16 @@ class HoudiniVarModel(BaseSettingsModel): _layout = "expanded" var: str = Field("", title="Var") value: str = Field("", title="Value") - is_dir_path: bool = Field(False, title="is Dir Path") + is_directory: bool = Field(False, title="Treat as directory") class UpdateHoudiniVarcontextModel(BaseSettingsModel): + """Houdini Vars Note. + + If a value is treated as a directory on update + it will be ensured the folder exists. + """ + enabled: bool = Field(title="Enabled") # TODO this was dynamic dictionary '{var: path}' houdini_vars: list[HoudiniVarModel] = Field( @@ -32,7 +38,7 @@ DEFAULT_GENERAL_SETTINGS = { { "var": "JOB", "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa - "is_dir_path": True + "is_directory": True } ] } diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png index 76fb7233214c685cfe632d05e937535280c723f0..74ac8d86c9fb9d7e0520e9922517039a36df20b6 100644 GIT binary patch literal 23727 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 From 7816c0370f705b796dc4f3cafa379b1d3117f681 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 19:34:02 +0300 Subject: [PATCH 140/327] Big Roy's comment --- .../schemas/projects_schema/schemas/schema_houdini_general.json | 2 +- server_addon/houdini/server/settings/general.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json index c1e2cae8f0..de1a0396ec 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -19,7 +19,7 @@ }, { "type": "label", - "label": "Houdini Vars.
If a value is treated as a directory on update it will be ensured the folder exists" + "label": "Sync vars with context changes.
If a value is treated as a directory on update it will be ensured the folder exists" }, { "type": "list", diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 0109eec63d..21cc4c452c 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -10,7 +10,7 @@ class HoudiniVarModel(BaseSettingsModel): class UpdateHoudiniVarcontextModel(BaseSettingsModel): - """Houdini Vars Note. + """Sync vars with context changes. If a value is treated as a directory on update it will be ensured the folder exists. From e6585e8d9dec22461bcd71e974324b8463558c2d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 19:37:53 +0300 Subject: [PATCH 141/327] update docs --- website/docs/admin_hosts_houdini.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 749ca43fe2..dd0e92f480 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -12,7 +12,7 @@ Using template keys is supported but formatting keys capitalization variants is :::note -If `is Dir Path` toggle is activated, Openpype will consider the given value is a path of a folder. +If `Treat as directory` toggle is activated, Openpype will consider the given value is a path of a folder. If the folder does not exist on the context change it will be created by this feature so that the path will always try to point to an existing folder. ::: From e75dc71ff7d97410756bc0343774621cd65f6d57 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 29 Sep 2023 19:41:10 +0300 Subject: [PATCH 142/327] update docs --- website/docs/admin_hosts_houdini.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index dd0e92f480..18c390e07f 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -21,6 +21,12 @@ Disabling `Update Houdini vars on context change` feature will leave all Houdini > If `$JOB` is present in the Houdini var list and has an empty value, OpenPype will set its value to `$HIP` + +:::note +For consistency reasons we always force all vars to be uppercase. +e.g. `myvar` will be `MYVAR` +::: + ![update-houdini-vars-context-change](assets/houdini/update-houdini-vars-context-change.png) From 8520a91cc8a3b7b18a2c63f09fc2b21cdd5599e9 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 30 Sep 2023 03:24:20 +0000 Subject: [PATCH 143/327] [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 f1e0cd0b80..8234258f19 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.1" +__version__ = "3.17.2-nightly.1" From f113ddb4eda84a8e112059edfce596327c6cf826 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 30 Sep 2023 03:24:56 +0000 Subject: [PATCH 144/327] 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 591d865ca5..9fb7bbc66c 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.17.2-nightly.1 - 3.17.1 - 3.17.1-nightly.3 - 3.17.1-nightly.2 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.8 - 3.14.10-nightly.7 - 3.14.10-nightly.6 - - 3.14.10-nightly.5 validations: required: true - type: dropdown From 6d451ccd09fab8bad13a42c21850605133660d03 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 30 Sep 2023 13:15:12 +0200 Subject: [PATCH 145/327] Use settings from `apply_settings` --- openpype/hosts/maya/api/plugin.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 79fcf9bc8b..157ce8368f 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -601,6 +601,13 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): class Loader(LoaderPlugin): hosts = ["maya"] + load_settings = {} # defined in settings + + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(Loader, cls).apply_settings(project_settings, system_settings) + cls.load_settings = project_settings['maya']['load'] + def get_custom_namespace_and_group(self, context, options, loader_key): """Queries Settings to get custom template for namespace and group. @@ -613,12 +620,9 @@ class Loader(LoaderPlugin): loader_key (str): key to get separate configuration from Settings ('reference_loader'|'import_loader') """ - options["attach_to_root"] = True - asset = context['asset'] - subset = context['subset'] - settings = get_project_settings(context['project']['name']) - custom_naming = settings['maya']['load'][loader_key] + options["attach_to_root"] = True + custom_naming = self.load_settings[loader_key] if not custom_naming['namespace']: raise LoadError("No namespace specified in " @@ -627,6 +631,8 @@ class Loader(LoaderPlugin): self.log.debug("No custom group_name, no group will be created.") options["attach_to_root"] = False + asset = context['asset'] + subset = context['subset'] formatting_data = { "asset_name": asset['name'], "asset_type": asset['type'], From 28dff4ed3880a008f5a5d3a2cccecb46b16ec4c2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 30 Sep 2023 13:16:02 +0200 Subject: [PATCH 146/327] Use project settings from context data --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 5ba256f9f5..58fa9d02bd 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -69,11 +69,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid = [] - project_settings = get_project_settings( - legacy_io.Session["AVALON_PROJECT"] - ) collision_prefixes = ( - project_settings + instance.context.data["project_settings"] ["maya"] ["create"] ["CreateUnrealStaticMesh"] From b0a62e3afee78fbad201e1b6c914e8c6241b1d85 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 30 Sep 2023 13:17:21 +0200 Subject: [PATCH 147/327] Remove unused imports --- openpype/hosts/maya/api/pipeline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 3647ec0b6b..04ff810873 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -28,8 +28,6 @@ from openpype.lib import ( from openpype.pipeline import ( legacy_io, get_current_project_name, - get_current_asset_name, - get_current_task_name, register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, From fe786236cddf4cb58ae56f03cf02fcfc29955545 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 30 Sep 2023 13:18:25 +0200 Subject: [PATCH 148/327] Move Muster module related submitter to Muster module --- .../muster}/plugins/publish/submit_maya_muster.py | 1 + 1 file changed, 1 insertion(+) rename openpype/{hosts/maya => modules/muster}/plugins/publish/submit_maya_muster.py (99%) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/modules/muster/plugins/publish/submit_maya_muster.py similarity index 99% rename from openpype/hosts/maya/plugins/publish/submit_maya_muster.py rename to openpype/modules/muster/plugins/publish/submit_maya_muster.py index c174fa7a33..3c3f901f87 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/modules/muster/plugins/publish/submit_maya_muster.py @@ -25,6 +25,7 @@ def _get_template_id(renderer): :rtype: int """ + # TODO: Use setings from context? templates = get_system_settings()["modules"]["muster"]["templates_mapping"] if not templates: raise RuntimeError(("Muster template mapping missing in " From 31e64d0ef819b0f934e94e7ed47e795991625ac3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 30 Sep 2023 13:25:55 +0200 Subject: [PATCH 149/327] Pass project settings to menu install so it doesn't need to also retrieve it --- openpype/hosts/maya/api/menu.py | 17 +++++++---------- openpype/hosts/maya/api/pipeline.py | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 715f54686c..18a4ea0e9a 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -1,14 +1,13 @@ import os import logging +from functools import partial from qtpy import QtWidgets, QtGui import maya.utils import maya.cmds as cmds -from openpype.settings import get_project_settings from openpype.pipeline import ( - get_current_project_name, get_current_asset_name, get_current_task_name ) @@ -46,12 +45,12 @@ def get_context_label(): ) -def install(): +def install(project_settings): if cmds.about(batch=True): log.info("Skipping openpype.menu initialization in batch mode..") return - def deferred(): + def add_menu(): pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( @@ -191,7 +190,7 @@ def install(): cmds.setParent(MENU_NAME, menu=True) - def add_scripts_menu(): + def add_scripts_menu(project_settings): try: import scriptsmenu.launchformaya as launchformaya except ImportError: @@ -201,9 +200,6 @@ def install(): ) return - # load configuration of custom menu - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) config = project_settings["maya"]["scriptsmenu"]["definition"] _menu = project_settings["maya"]["scriptsmenu"]["name"] @@ -225,8 +221,9 @@ def install(): # so that it only gets called after Maya UI has initialized too. # This is crucial with Maya 2020+ which initializes without UI # first as a QCoreApplication - maya.utils.executeDeferred(deferred) - cmds.evalDeferred(add_scripts_menu, lowestPriority=True) + maya.utils.executeDeferred(add_menu) + cmds.evalDeferred(partial(add_scripts_menu, project_settings), + lowestPriority=True) def uninstall(): diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 04ff810873..38d7ae08c1 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -106,7 +106,7 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): _set_project() self._register_callbacks() - menu.install() + menu.install(project_settings) register_event_callback("save", on_save) register_event_callback("open", on_open) From 00131ffd152cc27236d98a24a065a82a8bf1d566 Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 1 Oct 2023 20:15:22 +0800 Subject: [PATCH 150/327] refactor the validators for skeletonMesh and use rig validators as abstract class & minor tweak on collectors and settings --- .../plugins/publish/collect_skeleton_mesh.py | 5 +- .../plugins/publish/validate_rig_contents.py | 117 +++++++-- .../publish/validate_rig_controllers.py | 50 +++- .../publish/validate_rig_out_set_node_ids.py | 33 ++- .../publish/validate_rig_output_ids.py | 25 +- .../publish/validate_skeleton_rig_content.py | 101 -------- .../validate_skeleton_rig_controller.py | 222 ------------------ .../validate_skeleton_rig_out_set_node_ids.py | 90 ------- .../validate_skeleton_rig_output_ids.py | 124 ---------- .../defaults/project_settings/maya.json | 2 +- .../schemas/schema_maya_publish.json | 2 +- .../maya/server/settings/publishers.py | 2 +- 12 files changed, 211 insertions(+), 562 deletions(-) delete mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py delete mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py delete mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py delete mode 100644 openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index 9169e3dc28..648029c3fc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -12,7 +12,10 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): families = ["rig"] def process(self, instance): - skeleton_mesh_sets = instance.data.get("skeletonMesh_SET") + skeleton_mesh_sets = [ + i for i in instance + if i.lower().endswith("skeletonmesh_set") + ] if not skeleton_mesh_sets: self.log.debug( "skeletonMesh_SET found. " diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 23f031a5db..5b8faf6cae 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -25,19 +25,26 @@ class ValidateRigContents(pyblish.api.InstancePlugin): accepted_controllers = ["transform"] def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Invalid rig content. See log for details.") + + @classmethod + def get_invalid(cls, instance): # Find required sets by suffix - required = ["controls_SET", "out_SET"] + required, rig_sets = cls.get_nodes(instance) missing = [ - key for key in required if key not in instance.data["rig_sets"] + key for key in required if key not in rig_sets ] if missing: raise PublishValidationError( "%s is missing sets: %s" % (instance, ", ".join(missing)) ) - controls_set = instance.data["rig_sets"]["controls_SET"] - out_set = instance.data["rig_sets"]["out_SET"] + controls_set = rig_sets["controls_SET"] + out_set = rig_sets["out_SET"] # Ensure there are at least some transforms or dag nodes # in the rig instance @@ -76,31 +83,29 @@ class ValidateRigContents(pyblish.api.InstancePlugin): invalid_hierarchy.append(node) # Additional validations - invalid_geometry = self.validate_geometry(output_content) - invalid_controls = self.validate_controls(controls_content) + invalid_geometry = cls.validate_geometry(output_content) + invalid_controls = cls.validate_controls(controls_content) error = False if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " + cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_controls: - self.log.error("Only transforms can be part of the controls_SET." + cls.log.error("Only transforms can be part of the controls_SET." "\n%s" % invalid_controls) error = True if invalid_geometry: - self.log.error("Only meshes can be part of the out_SET\n%s" + cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True + return error - if error: - raise PublishValidationError( - "Invalid rig content. See log for details.") - - def validate_geometry(self, set_members): + @classmethod + def validate_geometry(cls, set_members): """Check if the out set passes the validations Checks if all its set members are within the hierarchy of the root @@ -122,12 +127,13 @@ class ValidateRigContents(pyblish.api.InstancePlugin): fullPath=True) or [] all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) for shape in all_shapes: - if cmds.nodeType(shape) not in self.accepted_output: + if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) return invalid - def validate_controls(self, set_members): + @classmethod + def validate_controls(cls, set_members): """Check if the controller set passes the validations Checks if all its set members are within the hierarchy of the root @@ -144,7 +150,84 @@ class ValidateRigContents(pyblish.api.InstancePlugin): # Validate control types invalid = [] for node in set_members: - if cmds.nodeType(node) not in self.accepted_controllers: + if cmds.nodeType(node) not in cls.accepted_controllers: invalid.append(node) return invalid + + @classmethod + def get_nodes(cls, instance): + objectsets = ["controls_SET", "out_SET"] + rig_sets_nodes = instance.data.get("rig_sets", []) + return objectsets, rig_sets_nodes + + +class ValidateSkeletonRigContents(ValidateRigContents): + """Ensure skeleton rigs contains pipeline-critical content + + The rigs optionally contain at least two object sets: + "skeletonMesh_SET" - Set of the skinned meshes + with bone hierarchies + + """ + + order = ValidateContentsOrder + label = "Skeleton Rig Contents" + hosts = ["maya"] + families = ["rig.fbx"] + + accepted_output = {"mesh", "transform", "locator"} + + @classmethod + def get_invalid(cls, instance): + objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) + missing = [ + key for key in objectsets if key not in instance.data["rig_sets"] + ] + if missing: + cls.log.debug( + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) + return + + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + raise PublishValidationError( + "No dag nodes in the pointcache instance. " + "(Empty instance?)" + ) + # Ensure contents in sets and retrieve long path for all objects + output_content = instance.data.get("skeleton_mesh", []) + output_content = cmds.ls(skeleton_mesh_nodes, long=True) + + # Validate members are inside the hierarchy from root node + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes + hierarchy = set(hierarchy) + error = False + invalid_hierarchy = [] + if output_content: + for node in output_content: + if node not in hierarchy: + invalid_hierarchy.append(node) + invalid_geometry = cls.validate_geometry(output_content) + if invalid_hierarchy: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if invalid_geometry: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + return error + + @classmethod + def get_nodes(cls, instance): + objectsets = ["skeletonMesh_SET"] + skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) + return objectsets, skeleton_mesh_nodes diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index a3828f871b..c1e3d96bae 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -59,7 +59,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Must have 'controls_SET' in rig instance" @@ -189,7 +189,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Unable to repair because no 'controls_SET' found in rig " @@ -228,3 +228,49 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get("controls_SET") + + +class ValidateSkeletonRigControllers(ValidateRigControllers): + """Validate rig controller for skeletonAnim_SET + + Controls must have the transformation attributes on their default + values of translate zero, rotate zero and scale one when they are + unlocked attributes. + + Unlocked keyable attributes may not have any incoming connections. If + these connections are required for the rig then lock the attributes. + + The visibility attribute must be locked. + + Note that `repair` will: + - Lock all visibility attributes + - Reset all default values for translate, rotate, scale + - Break all incoming connections to keyable attributes + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Controllers" + hosts = ["maya"] + families = ["rig.fbx"] + actions = [RepairAction, + openpype.hosts.maya.api.action.SelectInvalidAction] + + # Default controller values + CONTROLLER_DEFAULTS = { + "translateX": 0, + "translateY": 0, + "translateZ": 0, + "rotateX": 0, + "rotateY": 0, + "rotateZ": 0, + "scaleX": 1, + "scaleY": 1, + "scaleZ": 1 + } + + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get("skeletonAnim_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index fbd510c683..00eca608a1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -46,7 +46,7 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: return [] @@ -85,3 +85,34 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): continue lib.set_id(node, sibling_id, overwrite=True) + + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): + """Validate if deformed shapes have related IDs to the original shapes + from skeleton set. + + When a deformer is applied in the scene on a referenced mesh that already + had deformers then Maya will create a new shape node for the mesh that + does not have the original id. This validator checks whether the ids are + valid on all the shape nodes in the instance. + + """ + + order = ValidateContentsOrder + families = ["rig.fbx"] + hosts = ['maya'] + label = 'Skeleton Rig Out Set Node Ids' + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, + RepairAction + ] + allow_history_only = False + + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get( + "skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index 24fb36eb8b..e6204902f0 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: instance.data["mismatched_output_ids"] = invalid return invalid @@ -115,3 +115,26 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): "Multiple matched ids found. Please repair manually: " "{}".format(multiple_ids_match) ) + + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): + """Validate rig output ids from the skeleton sets. + + Ids must share the same id as similarly named nodes in the scene. This is + to ensure the id from the model is preserved through animation. + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Output Ids" + hosts = ["maya"] + families = ["rig.fbx"] + actions = [RepairAction, + openpype.hosts.maya.api.action.SelectInvalidAction] + + @classmethod + def get_node(cls, instance): + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py deleted file mode 100644 index 8b6cc74332..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_content.py +++ /dev/null @@ -1,101 +0,0 @@ -import pyblish.api -from maya import cmds - -from openpype.pipeline.publish import ( - PublishValidationError, - ValidateContentsOrder -) - - -class ValidateSkeletonRigContents(pyblish.api.InstancePlugin): - """Ensure skeleton rigs contains pipeline-critical content - - The rigs optionally contain at least two object sets: - "skeletonMesh_SET" - Set of the skinned meshes - with bone hierarchies - - """ - - order = ValidateContentsOrder - label = "Skeleton Rig Contents" - hosts = ["maya"] - families = ["rig.fbx"] - - accepted_output = {"mesh", "transform", "locator"} - - def process(self, instance): - objectsets = ["skeletonMesh_SET"] - missing = [ - key for key in objectsets if key not in instance.data["rig_sets"] - ] - if missing: - self.log.debug( - "%s is missing sets: %s" % (instance, ", ".join(missing)) - ) - return - - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - self.log.debug("Skipping instance without dag nodes...") - return - # Ensure contents in sets and retrieve long path for all objects - skeleton_mesh_content = instance.data.get("skeleton_mesh", []) - skeleton_mesh_content = cmds.ls(skeleton_mesh_content, long=True) - - # Validate members are inside the hierarchy from root node - root_node = cmds.ls(set_members, assemblies=True) - hierarchy = cmds.listRelatives(root_node, allDescendents=True, - fullPath=True) - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - if skeleton_mesh_content: - for node in skeleton_mesh_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - invalid_geometry = self.validate_geometry(skeleton_mesh_content) - - error = False - if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " - "while they are set up for publishing." - "\n%s" % invalid_hierarchy) - error = True - - if invalid_geometry: - self.log.error("Only meshes can be part of the " - "skeletonMesh_SET\n%s" % invalid_geometry) - error = True - - if error: - raise PublishValidationError( - "Invalid rig content. See log for details.") - - def validate_geometry(self, set_members): - """Check if the out set passes the validations - - Checks if all its set members are within the hierarchy of the root - Checks if the node types of the set members valid - - Args: - set_members: list of nodes of the skeleton_mesh_set - hierarchy: list of nodes which reside under the root node - - Returns: - errors (list) - """ - - # Validate all shape types - invalid = [] - shapes = cmds.listRelatives(set_members, - allDescendents=True, - shapes=True, - fullPath=True) or [] - all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) - for shape in all_shapes: - if cmds.nodeType(shape) not in self.accepted_output: - invalid.append(shape) - - return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py deleted file mode 100644 index a31d13bcec..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_controller.py +++ /dev/null @@ -1,222 +0,0 @@ -from maya import cmds - -import pyblish.api - -from openpype.pipeline.publish import ( - ValidateContentsOrder, - RepairAction, - PublishValidationError -) -import openpype.hosts.maya.api.action -from openpype.hosts.maya.api.lib import undo_chunk - - -class ValidateSkeletonRigControllers(pyblish.api.InstancePlugin): - """Validate rig controller for skeletonAnim_SET - - Controls must have the transformation attributes on their default - values of translate zero, rotate zero and scale one when they are - unlocked attributes. - - Unlocked keyable attributes may not have any incoming connections. If - these connections are required for the rig then lock the attributes. - - The visibility attribute must be locked. - - Note that `repair` will: - - Lock all visibility attributes - - Reset all default values for translate, rotate, scale - - Break all incoming connections to keyable attributes - - """ - order = ValidateContentsOrder + 0.05 - label = "Skeleton Rig Controllers" - hosts = ["maya"] - families = ["rig.fbx"] - actions = [RepairAction, - openpype.hosts.maya.api.action.SelectInvalidAction] - - # Default controller values - CONTROLLER_DEFAULTS = { - "translateX": 0, - "translateY": 0, - "translateZ": 0, - "rotateX": 0, - "rotateY": 0, - "rotateZ": 0, - "scaleX": 1, - "scaleY": 1, - "scaleZ": 1 - } - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError( - '{} failed, see log information'.format(self.label) - ) - - @classmethod - def get_invalid(cls, instance): - skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") - if not skeleton_set: - cls.log.info( - "No 'skeletonAnim_SET' in rig instance" - ) - return - controls = cmds.sets(skeleton_set, query=True) - lookup = set(instance[:]) - if not all(control in lookup for control in cmds.ls(controls, - long=True)): - cls.log.error( - "All controls must be inside the rig's group." - ) - return [controls] - # Validate all controls - has_connections = list() - has_unlocked_visibility = list() - has_non_default_values = list() - for control in controls: - if cls.get_connected_attributes(control): - has_connections.append(control) - - # check if visibility is locked - attribute = "{}.visibility".format(control) - locked = cmds.getAttr(attribute, lock=True) - if not locked: - has_unlocked_visibility.append(control) - - if cls.get_non_default_attributes(control): - has_non_default_values.append(control) - - if has_connections: - cls.log.error("Controls have input connections: " - "%s" % has_connections) - - if has_non_default_values: - cls.log.error("Controls have non-default values: " - "%s" % has_non_default_values) - - if has_unlocked_visibility: - cls.log.error("Controls have unlocked visibility " - "attribute: %s" % has_unlocked_visibility) - - invalid = [] - if (has_connections or - has_unlocked_visibility or - has_non_default_values): - invalid = set() - invalid.update(has_connections) - invalid.update(has_non_default_values) - invalid.update(has_unlocked_visibility) - invalid = list(invalid) - cls.log.error("Invalid rig controllers. See log for details.") - - return invalid - - @classmethod - def get_non_default_attributes(cls, control): - """Return attribute plugs with non-default values - - Args: - control (str): Name of control node. - - Returns: - list: The invalid plugs - - """ - - invalid = [] - for attr, default in cls.CONTROLLER_DEFAULTS.items(): - if cmds.attributeQuery(attr, node=control, exists=True): - plug = "{}.{}".format(control, attr) - - # Ignore locked attributes - locked = cmds.getAttr(plug, lock=True) - if locked: - continue - - value = cmds.getAttr(plug) - if value != default: - cls.log.warning("Control non-default value: " - "%s = %s" % (plug, value)) - invalid.append(plug) - - return invalid - - @staticmethod - def get_connected_attributes(control): - """Return attribute plugs with incoming connections. - - This will also ensure no (driven) keys on unlocked keyable attributes. - - Args: - control (str): Name of control node. - - Returns: - list: The invalid plugs - - """ - import maya.cmds as mc - - # Support controls without any attributes returning None - attributes = mc.listAttr(control, keyable=True, scalar=True) or [] - invalid = [] - for attr in attributes: - plug = "{}.{}".format(control, attr) - - # Ignore locked attributes - locked = cmds.getAttr(plug, lock=True) - if locked: - continue - - # Ignore proxy connections. - if (cmds.addAttr(plug, query=True, exists=True) and - cmds.addAttr(plug, query=True, usedAsProxy=True)): - continue - - # Check for incoming connections - if cmds.listConnections(plug, source=True, destination=False): - invalid.append(plug) - - return invalid - - @classmethod - def repair(cls, instance): - skeleton_set = instance.data["rig_sets"].get("skeletonAnim_SET") - if not skeleton_set: - cls.log.error( - "Unable to repair because no 'skeletonAnim_SET' found in rig " - "instance: {}".format(instance) - ) - return - # Use a single undo chunk - with undo_chunk(): - controls = cmds.sets(skeleton_set, query=True) - for control in controls: - # Lock visibility - attr = "{}.visibility".format(control) - locked = cmds.getAttr(attr, lock=True) - if not locked: - cls.log.info("Locking visibility for %s" % control) - cmds.setAttr(attr, lock=True) - - # Remove incoming connections - invalid_plugs = cls.get_connected_attributes(control) - if invalid_plugs: - for plug in invalid_plugs: - cls.log.info("Breaking input connection to %s" % plug) - source = cmds.listConnections(plug, - source=True, - destination=False, - plugs=True)[0] - cmds.disconnectAttr(source, plug) - - # Reset non-default values - invalid_plugs = cls.get_non_default_attributes(control) - if invalid_plugs: - for plug in invalid_plugs: - attr = plug.split(".")[-1] - default = cls.CONTROLLER_DEFAULTS[attr] - cls.log.info("Setting %s to %s" % (plug, default)) - cmds.setAttr(plug, default) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py deleted file mode 100644 index 73ad12f422..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_out_set_node_ids.py +++ /dev/null @@ -1,90 +0,0 @@ -import maya.cmds as cmds - -import pyblish.api - -import openpype.hosts.maya.api.action -from openpype.hosts.maya.api import lib -from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError -) - - -class ValidateSkeletonRigOutSetNodeIds(pyblish.api.InstancePlugin): - """Validate if deformed shapes have related IDs to the original shapes - from skeleton set. - - When a deformer is applied in the scene on a referenced mesh that already - had deformers then Maya will create a new shape node for the mesh that - does not have the original id. This validator checks whether the ids are - valid on all the shape nodes in the instance. - - """ - - order = ValidateContentsOrder - families = ["rig.fbx"] - hosts = ['maya'] - label = 'Skeleton Rig Out Set Node Ids' - actions = [ - openpype.hosts.maya.api.action.SelectInvalidAction, - RepairAction - ] - allow_history_only = False - - def process(self, instance): - """Process all meshes""" - - # Ensure all nodes have a cbId and a related ID to the original shapes - # if a deformer has been created on the shape - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError( - "Nodes found with mismatching IDs: {0}".format(invalid) - ) - - @classmethod - def get_invalid(cls, instance): - """Get all nodes which do not match the criteria""" - - skeletonMesh_set = instance.data["rig_sets"].get( - "skeletonMesh_SET") - if not skeletonMesh_set: - return [] - - invalid = [] - members = cmds.sets(skeletonMesh_set, query=True) - shapes = cmds.ls(members, - dag=True, - leaf=True, - shapes=True, - long=True, - noIntermediate=True) - if not shapes: - return [] - for shape in shapes: - sibling_id = lib.get_id_from_sibling( - shape, - history_only=cls.allow_history_only - ) - if sibling_id: - current_id = lib.get_id(shape) - if current_id != sibling_id: - invalid.append(shape) - - return invalid - - @classmethod - def repair(cls, instance): - - for node in cls.get_invalid(instance): - # Get the original id from sibling - sibling_id = lib.get_id_from_sibling( - node, - history_only=cls.allow_history_only - ) - if not sibling_id: - cls.log.error("Could not find ID in siblings for '%s'", node) - continue - - lib.set_id(node, sibling_id, overwrite=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py deleted file mode 100644 index 735ca27b39..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_skeleton_rig_output_ids.py +++ /dev/null @@ -1,124 +0,0 @@ -from collections import defaultdict - -from maya import cmds - -import pyblish.api - -import openpype.hosts.maya.api.action -from openpype.hosts.maya.api.lib import get_id, set_id -from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError -) - - -def get_basename(node): - """Return node short name without namespace""" - return node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] - - -class ValidateSkeletonRigOutputIds(pyblish.api.InstancePlugin): - """Validate rig output ids from the skeleton sets. - - Ids must share the same id as similarly named nodes in the scene. This is - to ensure the id from the model is preserved through animation. - - """ - order = ValidateContentsOrder + 0.05 - label = "Skeleton Rig Output Ids" - hosts = ["maya"] - families = ["rig.fbx"] - actions = [RepairAction, - openpype.hosts.maya.api.action.SelectInvalidAction] - - def process(self, instance): - invalid = self.get_invalid(instance, compute=True) - if invalid: - raise PublishValidationError("Found nodes with mismatched IDs.") - - @classmethod - def get_invalid(cls, instance, compute=False): - invalid_matches = cls.get_invalid_matches(instance, compute=compute) - - invalid_skeleton_matches = cls.get_invalid_matches( - instance, compute=compute, set_name="skeletonMesh_SET") - invalid_matches.update(invalid_skeleton_matches) - return list(invalid_matches.keys()) - - @classmethod - def get_invalid_matches(cls, instance, compute=False): - invalid = {} - - if compute: - skeletonMesh_set = instance.data["rig_sets"].get( - "skeletonMesh_SET") - if not skeletonMesh_set: - instance.data["mismatched_output_ids"] = invalid - return invalid - - instance_nodes = cmds.sets( - skeletonMesh_set, query=True, nodesOnly=True) - - instance_nodes = cmds.ls(instance_nodes, long=True) - if not instance_nodes: - return {} - for node in instance_nodes: - shapes = cmds.listRelatives(node, shapes=True, fullPath=True) - if shapes: - instance_nodes.extend(shapes) - - scene_nodes = cmds.ls(type=("transform", "mesh"), long=True) - - scene_nodes_by_basename = defaultdict(list) - for node in scene_nodes: - basename = get_basename(node) - scene_nodes_by_basename[basename].append(node) - - for instance_node in instance_nodes: - basename = get_basename(instance_node) - if basename not in scene_nodes_by_basename: - continue - - matches = scene_nodes_by_basename[basename] - - ids = set(get_id(node) for node in matches) - ids.add(get_id(instance_node)) - - if len(ids) > 1: - cls.log.error( - "\"{}\" id mismatch to: {}".format( - instance_node, matches - ) - ) - invalid[instance_node] = matches - - instance.data["mismatched_output_ids"] = invalid - else: - invalid = instance.data["mismatched_output_ids"] - - return invalid - - @classmethod - def repair(cls, instance): - invalid_matches = cls.get_invalid_matches(instance) - - multiple_ids_match = [] - for instance_node, matches in invalid_matches.items(): - ids = set(get_id(node) for node in matches) - - # If there are multiple scene ids matched, an error needs to be - # raised for manual correction. - if len(ids) > 1: - multiple_ids_match.append({"node": instance_node, - "matches": matches}) - continue - - id_to_set = next(iter(ids)) - set_id(instance_node, id_to_set, overwrite=True) - - if multiple_ids_match: - raise PublishValidationError( - "Multiple matched ids found. Please repair manually: " - "{}".format(multiple_ids_match) - ) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index d3e01287e5..5e11227d68 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1179,7 +1179,7 @@ "active": true }, "ValidateSkeletonTopGroupHierarchy": { - "enabled": false, + "enabled": true, "optional": true, "active": true }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index f2bbc0f70b..f4db51a079 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -829,7 +829,7 @@ }, { "key": "ValidateSkeletonRigContents", - "label": "ValidateSkeleton Rig Contents" + "label": "Validate Skeleton Rig Contents" }, { "key": "ValidateSkeletonRigControllers", diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index cb3af191a8..6c5baa3900 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -1234,7 +1234,7 @@ DEFAULT_PUBLISH_SETTINGS = { "active": True }, "ValidateSkeletonTopGroupHierarchy": { - "enabled": False, + "enabled": True, "optional": True, "active": True }, From 64f436a74dc69eb5506ecb9c986a426387b894ad Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 1 Oct 2023 20:17:21 +0800 Subject: [PATCH 151/327] hound --- .../hosts/maya/plugins/publish/validate_rig_contents.py | 8 ++++---- .../maya/plugins/publish/validate_rig_controllers.py | 1 + .../hosts/maya/plugins/publish/validate_rig_output_ids.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 5b8faf6cae..f3c2231b1f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -216,13 +216,13 @@ class ValidateSkeletonRigContents(ValidateRigContents): invalid_geometry = cls.validate_geometry(output_content) if invalid_hierarchy: cls.log.error("Found nodes which reside outside of root group " - "while they are set up for publishing." - "\n%s" % invalid_hierarchy) + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) error = True if invalid_geometry: cls.log.error("Found nodes which reside outside of root group " - "while they are set up for publishing." - "\n%s" % invalid_hierarchy) + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) error = True return error diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index c1e3d96bae..4e86e9859f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -228,6 +228,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) + @classmethod def get_node(cls, instance): return instance.data["rig_sets"].get("controls_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index e6204902f0..cd6ac511e2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = cls.get_node(instance) + out_set = cls.get_node(instance) if not out_set: instance.data["mismatched_output_ids"] = invalid return invalid From 7f5879b0e8d12761add2ea5ec527259c462b567e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 1 Oct 2023 22:46:16 +0200 Subject: [PATCH 152/327] Avoid memory leak - actually clear stored plugins on reset --- openpype/tools/publisher/control.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index d4e0ae0453..677c1da51a 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -194,6 +194,7 @@ class PublishReportMaker: self._publish_discover_result = create_context.publish_discover_result self._plugin_data = [] self._plugin_data_with_plugin = [] + self._stored_plugins = [] self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = context From 52965a827597532c7f11eee3592e51fd8473f650 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 1 Oct 2023 22:46:51 +0200 Subject: [PATCH 153/327] Use `set` since it's supposed to be unique entries and is used in many lookups --- openpype/tools/publisher/control.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 677c1da51a..e6b68906fd 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -179,7 +179,7 @@ class PublishReportMaker: self._plugin_data = [] self._plugin_data_with_plugin = [] - self._stored_plugins = [] + self._stored_plugins = set() self._current_plugin_data = [] self._all_instances_by_id = {} self._current_context = None @@ -194,7 +194,7 @@ class PublishReportMaker: self._publish_discover_result = create_context.publish_discover_result self._plugin_data = [] self._plugin_data_with_plugin = [] - self._stored_plugins = [] + self._stored_plugins = set() self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = context @@ -230,7 +230,7 @@ class PublishReportMaker: raise ValueError( "Plugin '{}' is already stored".format(str(plugin))) - self._stored_plugins.append(plugin) + self._stored_plugins.add(plugin) plugin_data_item = self._create_plugin_data_item(plugin) From 61f7a2039b60567416d80005c046aab7a5e28de2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 00:57:22 +0200 Subject: [PATCH 154/327] Update openpype/modules/muster/plugins/publish/submit_maya_muster.py --- openpype/modules/muster/plugins/publish/submit_maya_muster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/muster/plugins/publish/submit_maya_muster.py b/openpype/modules/muster/plugins/publish/submit_maya_muster.py index 3c3f901f87..5c95744876 100644 --- a/openpype/modules/muster/plugins/publish/submit_maya_muster.py +++ b/openpype/modules/muster/plugins/publish/submit_maya_muster.py @@ -25,7 +25,7 @@ def _get_template_id(renderer): :rtype: int """ - # TODO: Use setings from context? + # TODO: Use settings from context? templates = get_system_settings()["modules"]["muster"]["templates_mapping"] if not templates: raise RuntimeError(("Muster template mapping missing in " From 0ddf5ffd90a996037bdaa8905e6fda1d37b4e08a Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 2 Oct 2023 12:29:41 +0800 Subject: [PATCH 155/327] minor tweak & abstract some codes into functions in rig content --- openpype/hosts/maya/api/fbx.py | 2 +- .../plugins/publish/extract_fbx_animation.py | 4 +- .../plugins/publish/extract_skeleton_mesh.py | 2 +- .../plugins/publish/validate_rig_contents.py | 141 +++++++++++------- .../publish/validate_rig_controllers.py | 18 ++- .../publish/validate_rig_out_set_node_ids.py | 16 ++ .../publish/validate_rig_output_ids.py | 16 ++ 7 files changed, 136 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 2dd4f5a73d..dbb3578f08 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -64,7 +64,7 @@ class FBXExtractor: "inputConnections": bool, "upAxis": str, # x, y or z, "triangulate": bool, - "FileVersion": str, + "fileVersion": str, "skeletonDefinitions": bool, "referencedAssetsContent": bool } diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 115ba39986..d67fca4e85 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -45,7 +45,7 @@ class ExtractFBXAnimation(publish.Extractor): # names as existing in the rig workfile namespace, relative_out_set = out_set_name.split(":", 1) cmds.namespace(relativeNames=True) - with namespaced(":" + namespace,new=False, relative_names=True) as namespace: # noqa + with namespaced(":" + namespace, new=False, relative_names=True) as namespace: # noqa fbx_exporter.export(relative_out_set, path) representations = instance.data.setdefault("representations", []) @@ -57,4 +57,4 @@ class ExtractFBXAnimation(publish.Extractor): }) self.log.debug( - "Extracted Fbx animation to: {0}".format(path)) + "Extracted FBX animation to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py index cecdf282e2..50c1fb3bde 100644 --- a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -51,4 +51,4 @@ class ExtractSkeletonMesh(publish.Extractor, "stagingDir": staging_dir }) - self.log.debug("Extract animated FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index f3c2231b1f..c63d0e0a2e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -35,26 +35,12 @@ class ValidateRigContents(pyblish.api.InstancePlugin): # Find required sets by suffix required, rig_sets = cls.get_nodes(instance) - missing = [ - key for key in required if key not in rig_sets - ] - if missing: - raise PublishValidationError( - "%s is missing sets: %s" % (instance, ", ".join(missing)) - ) + + cls.validate_missing_objectsets(instance, required, rig_sets) controls_set = rig_sets["controls_SET"] out_set = rig_sets["out_SET"] - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - raise PublishValidationError( - "No dag nodes in the pointcache instance. " - "(Empty instance?)" - ) - # Ensure contents in sets and retrieve long path for all objects output_content = cmds.sets(out_set, query=True) or [] if not output_content: @@ -68,19 +54,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): ) controls_content = cmds.ls(controls_content, long=True) - # Validate members are inside the hierarchy from root node - root_nodes = cmds.ls(set_members, assemblies=True, long=True) - hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, - fullPath=True) + root_nodes - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - for node in controls_content: - if node not in hierarchy: - invalid_hierarchy.append(node) + rig_content = output_content + controls_content + invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content) # Additional validations invalid_geometry = cls.validate_geometry(output_content) @@ -104,6 +79,62 @@ class ValidateRigContents(pyblish.api.InstancePlugin): error = True return error + @classmethod + def validate_missing_objectsets(cls, instance, + required_objsets, rig_sets): + """Validate missing objectsets in rig sets + + Args: + instance (str): instance + required_objsets (list): list of objectset names + rig_sets (list): list of rig sets + + Raises: + PublishValidationError: When the error is raised, it will show + which instance has the missing object sets + """ + missing = [ + key for key in required_objsets if key not in rig_sets + ] + if missing: + raise PublishValidationError( + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) + + @classmethod + def invalid_hierarchy(cls, instance, content): + """_summary_ + + Args: + instance (str): instance + content (list): list of content from rig sets + + Raises: + PublishValidationError: It means no dag nodes in + the rig instance + + Returns: + list: invalid hierarchy + """ + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + raise PublishValidationError( + "No dag nodes in the rig instance. " + "(Empty instance?)" + ) + # Validate members are inside the hierarchy from root node + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes + hierarchy = set(hierarchy) + invalid_hierarchy = [] + for node in content: + if node not in hierarchy: + invalid_hierarchy.append(node) + return invalid_hierarchy + @classmethod def validate_geometry(cls, set_members): """Check if the out set passes the validations @@ -130,8 +161,6 @@ class ValidateRigContents(pyblish.api.InstancePlugin): if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) - return invalid - @classmethod def validate_controls(cls, set_members): """Check if the controller set passes the validations @@ -157,6 +186,14 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + list: list of objectsets, list of rig sets nodes + """ objectsets = ["controls_SET", "out_SET"] rig_sets_nodes = instance.data.get("rig_sets", []) return objectsets, rig_sets_nodes @@ -181,39 +218,18 @@ class ValidateSkeletonRigContents(ValidateRigContents): @classmethod def get_invalid(cls, instance): objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) - missing = [ - key for key in objectsets if key not in instance.data["rig_sets"] - ] - if missing: - cls.log.debug( - "%s is missing sets: %s" % (instance, ", ".join(missing)) - ) - return + cls.validate_missing_objectsets( + instance, objectsets, instance.data["rig_sets"]) - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - raise PublishValidationError( - "No dag nodes in the pointcache instance. " - "(Empty instance?)" - ) # Ensure contents in sets and retrieve long path for all objects output_content = instance.data.get("skeleton_mesh", []) output_content = cmds.ls(skeleton_mesh_nodes, long=True) - # Validate members are inside the hierarchy from root node - root_nodes = cmds.ls(set_members, assemblies=True, long=True) - hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, - fullPath=True) + root_nodes - hierarchy = set(hierarchy) + invalid_hierarchy = cls.invalid_hierarchy( + instance, output_content) + invalid_geometry = cls.validate_geometry(output_content) + error = False - invalid_hierarchy = [] - if output_content: - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - invalid_geometry = cls.validate_geometry(output_content) if invalid_hierarchy: cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." @@ -228,6 +244,15 @@ class ValidateSkeletonRigContents(ValidateRigContents): @classmethod def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + list: list of objectsets, + list of objects node from skeletonMesh_SET + """ objectsets = ["skeletonMesh_SET"] skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) return objectsets, skeleton_mesh_nodes diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 4e86e9859f..a10e2158fa 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -231,6 +231,14 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def get_node(cls, instance): + """Get target object nodes from controls_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from controls_SET + """ return instance.data["rig_sets"].get("controls_SET") @@ -274,4 +282,12 @@ class ValidateSkeletonRigControllers(ValidateRigControllers): @classmethod def get_node(cls, instance): - return instance.data["rig_sets"].get("skeletonAnim_SET") + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 00eca608a1..6f713a3ca1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -88,6 +88,14 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): @classmethod def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ return instance.data["rig_sets"].get("out_SET") @@ -114,5 +122,13 @@ class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): @classmethod def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ return instance.data["rig_sets"].get( "skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index cd6ac511e2..ec46b2be87 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -118,6 +118,14 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): @classmethod def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ return instance.data["rig_sets"].get("out_SET") @@ -137,4 +145,12 @@ class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): @classmethod def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ return instance.data["rig_sets"].get("skeletonMesh_SET") From 2850df81b9c73b6d9ffabebf7b8f9a28b5b9c959 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 2 Oct 2023 17:52:34 +0800 Subject: [PATCH 156/327] fix the over-indented of the namespace function under context manager --- openpype/hosts/maya/api/lib.py | 4 ++-- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index c05e375681..fb19cd64a6 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -938,8 +938,8 @@ def namespaced(namespace, new=True, relative_names=None): if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) - if relative_names is not None: - cmds.namespace(relativeNames=relative_names) + if relative_names is not None: + cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index d67fca4e85..f9e696489e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,7 +44,6 @@ class ExtractFBXAnimation(publish.Extractor): # FBX does not include the namespace but preserves the node # names as existing in the rig workfile namespace, relative_out_set = out_set_name.split(":", 1) - cmds.namespace(relativeNames=True) with namespaced(":" + namespace, new=False, relative_names=True) as namespace: # noqa fbx_exporter.export(relative_out_set, path) From 6b3aa6a247c895507771d414c653757634628727 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 2 Oct 2023 12:45:18 +0100 Subject: [PATCH 157/327] Added Cycles render passes --- openpype/hosts/blender/api/render_lib.py | 8 +++++++- .../schemas/projects_schema/schema_project_blender.json | 5 ++++- server_addon/blender/server/settings/render_settings.py | 5 ++++- server_addon/blender/server/version.py | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/api/render_lib.py b/openpype/hosts/blender/api/render_lib.py index 43560ee6d5..d564b5ebcb 100644 --- a/openpype/hosts/blender/api/render_lib.py +++ b/openpype/hosts/blender/api/render_lib.py @@ -116,6 +116,12 @@ def set_render_passes(settings): vl.use_pass_shadow = "shadow" in aov_list vl.use_pass_ambient_occlusion = "ao" in aov_list + cycles = vl.cycles + + cycles.denoising_store_passes = "denoising" in aov_list + cycles.use_pass_volume_direct = "volume_direct" in aov_list + cycles.use_pass_volume_indirect = "volume_indirect" in aov_list + aovs_names = [aov.name for aov in vl.aovs] for cp in custom_passes: cp_name = cp[0] @@ -149,7 +155,7 @@ def set_node_tree(output_path, name, aov_sep, ext, multilayer): # Get the enabled output sockets, that are the active passes for the # render. # We also exclude some layers. - exclude_sockets = ["Image", "Alpha"] + exclude_sockets = ["Image", "Alpha", "Noisy Image"] passes = [ socket for socket in rl_node.outputs diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index 4c9405fcd3..535d9434a3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -123,7 +123,10 @@ {"emission": "Emission"}, {"environment": "Environment"}, {"shadow": "Shadow"}, - {"ao": "Ambient Occlusion"} + {"ao": "Ambient Occlusion"}, + {"denoising": "Denoising"}, + {"volume_direct": "Direct Volumetric Scattering"}, + {"volume_indirect": "Indirect Volumetric Scattering"} ] }, { diff --git a/server_addon/blender/server/settings/render_settings.py b/server_addon/blender/server/settings/render_settings.py index 7a47095d3c..f62013982e 100644 --- a/server_addon/blender/server/settings/render_settings.py +++ b/server_addon/blender/server/settings/render_settings.py @@ -40,7 +40,10 @@ def aov_list_enum(): {"value": "emission", "label": "Emission"}, {"value": "environment", "label": "Environment"}, {"value": "shadow", "label": "Shadow"}, - {"value": "ao", "label": "Ambient Occlusion"} + {"value": "ao", "label": "Ambient Occlusion"}, + {"value": "denoising", "label": "Denoising"}, + {"value": "volume_direct", "label": "Direct Volumetric Scattering"}, + {"value": "volume_indirect", "label": "Indirect Volumetric Scattering"} ] diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 8e1f3beff6e68ec0111ec68754050dc9768754ab Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 2 Oct 2023 12:45:50 +0100 Subject: [PATCH 158/327] Fixed render job environment variables --- .../modules/deadline/plugins/publish/submit_blender_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 307fc8b5a2..4a7497b075 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -123,7 +123,7 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.EnvironmentKeyValue[key] = value # to recognize job from PYPE for turning Event On/Off - job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + job_info.add_render_job_env_var() job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Adding file dependencies. From 333c282eba0467883e3709245e8f0b763537155c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 14:24:02 +0200 Subject: [PATCH 159/327] Don't query comp again --- openpype/hosts/fusion/plugins/load/load_sequence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index fde5b27e70..4401af97eb 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -161,7 +161,6 @@ class FusionLoadSequence(load.LoaderPlugin): with comp_lock_and_undo_chunk(comp, "Create Loader"): args = (-32768, -32768) tool = comp.AddTool("Loader", *args) - comp = get_current_comp() tool["Clip"] = comp.ReverseMapPath(path) # Set global in point to start frame (if in version.data) From 73a122b79a1069e767227307cb30dc23446f8c61 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 15:09:30 +0200 Subject: [PATCH 160/327] Restore formatting of non-modified code --- .../fusion/plugins/create/create_saver.py | 57 +++++++++++-------- .../fusion/plugins/publish/collect_render.py | 12 ++-- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 6b38af6ee4..2c627666b6 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -14,7 +14,7 @@ from openpype.pipeline import ( legacy_io, Creator as NewCreator, CreatedInstance, - Anatomy, + Anatomy ) @@ -33,16 +33,19 @@ class CreateSaver(NewCreator): # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( - "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}" - ) + "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}") def create(self, subset_name, instance_data, pre_create_data): - self.pass_pre_attributes_to_instance(instance_data, pre_create_data) - - instance_data.update( - {"id": "pyblish.avalon.instance", "subset": subset_name} + self.pass_pre_attributes_to_instance( + instance_data, + pre_create_data ) + instance_data.update({ + "id": "pyblish.avalon.instance", + "subset": subset_name + }) + # TODO: Add pre_create attributes to choose file format? file_format = "OpenEXRFormat" @@ -149,12 +152,15 @@ class CreateSaver(NewCreator): # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) - formatting_data.update( - {"workdir": workdir, "frame": "0" * frame_padding, "ext": "exr"} - ) + formatting_data.update({ + "workdir": workdir, + "frame": "0" * frame_padding, + "ext": "exr" + }) # build file path to render - filepath = self.temp_rendering_path_template.format(**formatting_data) + filepath = self.temp_rendering_path_template.format( + **formatting_data) comp = get_current_comp() tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) @@ -190,7 +196,7 @@ class CreateSaver(NewCreator): attr_defs = [ self._get_render_target_enum(), self._get_reviewable_bool(), - self._get_frame_range_enum(), + self._get_frame_range_enum() ] return attr_defs @@ -198,7 +204,11 @@ class CreateSaver(NewCreator): """Settings for publish page""" return self.get_pre_create_attr_defs() - def pass_pre_attributes_to_instance(self, instance_data, pre_create_data): + def pass_pre_attributes_to_instance( + self, + instance_data, + pre_create_data + ): creator_attrs = instance_data["creator_attributes"] = {} for pass_key in pre_create_data.keys(): creator_attrs[pass_key] = pre_create_data[pass_key] @@ -221,13 +231,13 @@ class CreateSaver(NewCreator): frame_range_options = { "asset_db": "Current asset context", "render_range": "From render in/out", - "comp_range": "From composition timeline", + "comp_range": "From composition timeline" } return EnumDef( "frame_range_source", items=frame_range_options, - label="Frame range source", + label="Frame range source" ) def _get_reviewable_bool(self): @@ -242,18 +252,15 @@ class CreateSaver(NewCreator): """Method called on initialization of plugin to apply settings.""" # plugin settings - plugin_settings = project_settings["fusion"]["create"][ - self.__class__.__name__ - ] + plugin_settings = ( + project_settings["fusion"]["create"][self.__class__.__name__] + ) # individual attributes - self.instance_attributes = ( - plugin_settings.get("instance_attributes") - or self.instance_attributes - ) - self.default_variants = ( - plugin_settings.get("default_variants") or self.default_variants - ) + self.instance_attributes = plugin_settings.get( + "instance_attributes") or self.instance_attributes + self.default_variants = plugin_settings.get( + "default_variants") or self.default_variants self.temp_rendering_path_template = ( plugin_settings.get("temp_rendering_path_template") or self.temp_rendering_path_template diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index 117347a4c2..facc9e6aef 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -25,13 +25,16 @@ class FusionRenderInstance(RenderInstance): class CollectFusionRender( - publish.AbstractCollectRender, publish.ColormanagedPyblishPluginMixin + publish.AbstractCollectRender, + publish.ColormanagedPyblishPluginMixin ): + order = pyblish.api.CollectorOrder + 0.09 label = "Collect Fusion Render" hosts = ["fusion"] def get_instances(self, context): + comp = context.data.get("currentComp") comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat") aspect_x = comp_frame_format_prefs["AspectX"] @@ -71,7 +74,7 @@ class CollectFusionRender( asset=inst.data["asset"], task=task_name, attachTo=False, - setMembers="", + setMembers='', publish=True, name=subset_name, resolutionWidth=comp_frame_format_prefs.get("Width"), @@ -90,7 +93,7 @@ class CollectFusionRender( frameStep=1, fps=comp_frame_format_prefs.get("Rate"), app_version=comp.GetApp().Version, - publish_attributes=inst.data.get("publish_attributes", {}), + publish_attributes=inst.data.get("publish_attributes", {}) ) render_target = inst.data["creator_attributes"]["render_target"] @@ -162,7 +165,8 @@ class CollectFusionRender( for frame in range(start, end + 1): expected_files.append( os.path.join( - output_dir, f"{head}{str(frame).zfill(padding)}{ext}" + output_dir, + f"{head}{str(frame).zfill(padding)}{ext}" ) ) From 441bb73afc303cca3cf4da95fb623c4609426f76 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 15:10:46 +0200 Subject: [PATCH 161/327] hound --- 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 2c627666b6..edac113e85 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -160,7 +160,7 @@ class CreateSaver(NewCreator): # build file path to render filepath = self.temp_rendering_path_template.format( - **formatting_data) + **formatting_data) comp = get_current_comp() tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) From bd31dbaf35d9f2a9c054223827d58cf34ebe14dd Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 16:12:20 +0200 Subject: [PATCH 162/327] Get the comp from render_instance instead of get_current_comp() --- openpype/hosts/fusion/plugins/publish/collect_render.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index facc9e6aef..5474b677cf 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -6,7 +6,6 @@ from openpype.pipeline import publish from openpype.pipeline.publish import RenderInstance from openpype.hosts.fusion.api.lib import ( get_frame_path, - get_current_comp, ) @@ -148,7 +147,7 @@ class CollectFusionRender( start = render_instance.frameStart - render_instance.handleStart end = render_instance.frameEnd + render_instance.handleEnd - comp = get_current_comp() + comp = render_instance.workfileComp path = comp.MapPath( render_instance.tool["Clip"][ render_instance.workfileComp.TIME_UNDEFINED From 35b3006f29f0c5c197abb2f010a6e51f6d214c95 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 2 Oct 2023 15:34:48 +0100 Subject: [PATCH 163/327] Improved naming for RenderProduct --- openpype/hosts/blender/api/colorspace.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/colorspace.py b/openpype/hosts/blender/api/colorspace.py index 0f504a3be0..4521612b7d 100644 --- a/openpype/hosts/blender/api/colorspace.py +++ b/openpype/hosts/blender/api/colorspace.py @@ -22,12 +22,11 @@ class RenderProduct(object): class ARenderProduct(object): - def __init__(self): """Constructor.""" # Initialize self.layer_data = self._get_layer_data() - self.layer_data.products = self.get_colorspace_data() + self.layer_data.products = self.get_render_products() def _get_layer_data(self): scene = bpy.context.scene @@ -37,7 +36,7 @@ class ARenderProduct(object): frameEnd=int(scene.frame_end), ) - def get_colorspace_data(self): + def get_render_products(self): """To be implemented by renderer class. This should return a list of RenderProducts. Returns: From 42f7549e059e9bdf85b6c360b9808bf02b7727e5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 16:37:45 +0200 Subject: [PATCH 164/327] resolve: adding input arg to create new timeline --- openpype/hosts/resolve/api/lib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index eaee3bb9ba..a88564a3ef 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -125,7 +125,7 @@ def get_any_timeline(): return project.GetTimelineByIndex(1) -def get_new_timeline(): +def get_new_timeline(timeline_name: str = None): """Get new timeline object. Returns: @@ -133,7 +133,8 @@ def get_new_timeline(): """ project = get_current_project() media_pool = project.GetMediaPool() - new_timeline = media_pool.CreateEmptyTimeline(self.pype_timeline_name) + new_timeline = media_pool.CreateEmptyTimeline( + timeline_name or self.pype_timeline_name) project.SetCurrentTimeline(new_timeline) return new_timeline From b8cee701a36742a40b8111227bd5c30bc8f183d9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 16:38:42 +0200 Subject: [PATCH 165/327] resolve: load multiple clips to new timeline fix --- openpype/hosts/resolve/api/plugin.py | 40 ++++++++++++------- .../hosts/resolve/plugins/load/load_clip.py | 6 --- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index e2bd76ffa2..ddf0df662b 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -291,17 +291,17 @@ class ClipLoader: active_bin = None data = dict() - def __init__(self, cls, context, path, **options): + def __init__(self, loader_obj, context, path, **options): """ Initialize object Arguments: - cls (openpype.pipeline.load.LoaderPlugin): plugin object + loader_obj (openpype.pipeline.load.LoaderPlugin): plugin object context (dict): loader plugin context options (dict)[optional]: possible keys: projectBinPath: "path/to/binItem" """ - self.__dict__.update(cls.__dict__) + self.__dict__.update(loader_obj.__dict__) self.context = context self.active_project = lib.get_current_project() self.fname = path @@ -319,23 +319,29 @@ class ClipLoader: # inject asset data to representation dict self._get_asset_data() - print("__init__ self.data: `{}`".format(self.data)) # add active components to class if self.new_timeline: - if options.get("timeline"): + loader_cls = loader_obj.__class__ + if loader_cls.timeline: # if multiselection is set then use options sequence - self.active_timeline = options["timeline"] + self.active_timeline = loader_cls.timeline else: # create new sequence - self.active_timeline = ( - lib.get_current_timeline() or - lib.get_new_timeline() + self.active_timeline = lib.get_new_timeline( + "{}_{}_{}".format( + self.subset, + self.representation, + str(uuid.uuid4())[:8] + ) ) + loader_cls.timeline = self.active_timeline + + print(self.active_timeline.GetName()) else: self.active_timeline = lib.get_current_timeline() - cls.timeline = self.active_timeline + def _populate_data(self): """ Gets context and convert it to self.data @@ -349,10 +355,14 @@ class ClipLoader: # create name repr = self.context["representation"] repr_cntx = repr["context"] - asset = str(repr_cntx["asset"]) - subset = str(repr_cntx["subset"]) - representation = str(repr_cntx["representation"]) - self.data["clip_name"] = "_".join([asset, subset, representation]) + self.asset = str(repr_cntx["asset"]) + self.subset = str(repr_cntx["subset"]) + self.representation = str(repr_cntx["representation"]) + self.data["clip_name"] = "_".join([ + self.asset, + self.subset, + self.representation + ]) self.data["versionData"] = self.context["version"]["data"] # gets file path file = self.fname @@ -367,7 +377,7 @@ class ClipLoader: hierarchy = str("/".join(( "Loader", repr_cntx["hierarchy"].replace("\\", "/"), - asset + self.asset ))) self.data["binPath"] = hierarchy diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index 3a59ecea80..1d66c97041 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -48,12 +48,6 @@ class LoadClip(plugin.TimelineItemLoader): def load(self, context, name, namespace, options): - # in case loader uses multiselection - if self.timeline: - options.update({ - "timeline": self.timeline, - }) - # load clip to timeline and get main variables path = self.filepath_from_context(context) timeline_item = plugin.ClipLoader( From 3328ba321db903218b2c28d16676cf6dffd34573 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:01:54 +0200 Subject: [PATCH 166/327] AYON Workfiles Tool: Open workfile changes context (#5671) * change context when opening workfile * do not call 'set_context' in blender * removed unused import --- openpype/hosts/blender/api/ops.py | 10 +-- openpype/tools/ayon_workfiles/abstract.py | 6 +- openpype/tools/ayon_workfiles/control.py | 67 +++++++++++++++---- .../ayon_workfiles/widgets/files_widget.py | 31 ++++++--- 4 files changed, 85 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 62d7987b47..0eb90eeff9 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -16,6 +16,7 @@ import bpy import bpy.utils.previews from openpype import style +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name, get_current_task_name from openpype.tools.utils import host_tools @@ -331,10 +332,11 @@ class LaunchWorkFiles(LaunchQtApp): def execute(self, context): result = super().execute(context) - self._window.set_context({ - "asset": get_current_asset_name(), - "task": get_current_task_name() - }) + if not AYON_SERVER_ENABLED: + self._window.set_context({ + "asset": get_current_asset_name(), + "task": get_current_task_name() + }) return result def before_window_show(self): diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py index f511181837..ce399fd4c6 100644 --- a/openpype/tools/ayon_workfiles/abstract.py +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -914,10 +914,12 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): # Controller actions @abstractmethod - def open_workfile(self, filepath): - """Open a workfile. + def open_workfile(self, folder_id, task_id, filepath): + """Open a workfile for context. Args: + folder_id (str): Folder id. + task_id (str): Task id. filepath (str): Workfile path. """ diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index 1153a3c01f..3784959caf 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -452,12 +452,12 @@ class BaseWorkfileController( self._emit_event("controller.refresh.finished") # Controller actions - def open_workfile(self, filepath): + def open_workfile(self, folder_id, task_id, filepath): self._emit_event("open_workfile.started") failed = False try: - self._host_open_workfile(filepath) + self._open_workfile(folder_id, task_id, filepath) except Exception: failed = True @@ -575,6 +575,53 @@ class BaseWorkfileController( self._expected_selection.get_expected_selection_data(), ) + def _get_event_context_data( + self, project_name, folder_id, task_id, folder=None, task=None + ): + if folder is None: + folder = self.get_folder_entity(folder_id) + if task is None: + task = self.get_task_entity(task_id) + # NOTE keys should be OpenPype compatible + return { + "project_name": project_name, + "folder_id": folder_id, + "asset_id": folder_id, + "asset_name": folder["name"], + "task_id": task_id, + "task_name": task["name"], + "host_name": self.get_host_name(), + } + + def _open_workfile(self, folder_id, task_id, filepath): + project_name = self.get_current_project_name() + event_data = self._get_event_context_data( + project_name, folder_id, task_id + ) + event_data["filepath"] = filepath + + emit_event("workfile.open.before", event_data, source="workfiles.tool") + + # Change context + task_name = event_data["task_name"] + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id( + event_data["project_name"], + event_data["folder_id"], + ) + change_current_context( + asset_doc, + event_data["task_name"] + ) + + self._host_open_workfile(filepath) + + emit_event("workfile.open.after", event_data, source="workfiles.tool") + def _save_as_workfile( self, folder_id, @@ -591,18 +638,14 @@ class BaseWorkfileController( task_name = task["name"] # QUESTION should the data be different for 'before' and 'after'? - # NOTE keys should be OpenPype compatible - event_data = { - "project_name": project_name, - "folder_id": folder_id, - "asset_id": folder_id, - "asset_name": folder["name"], - "task_id": task_id, - "task_name": task_name, - "host_name": self.get_host_name(), + event_data = self._get_event_context_data( + project_name, folder_id, task_id, folder, task + ) + event_data.update({ "filename": filename, "workdir_path": workdir, - } + }) + emit_event("workfile.save.before", event_data, source="workfiles.tool") # Create workfiles root folder diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py index fbf4dbc593..656ddf1dd8 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -106,7 +106,8 @@ class FilesWidget(QtWidgets.QWidget): self._on_published_cancel_clicked) self._selected_folder_id = None - self._selected_tak_name = None + self._selected_task_id = None + self._selected_task_name = None self._pre_select_folder_id = None self._pre_select_task_name = None @@ -178,7 +179,7 @@ class FilesWidget(QtWidgets.QWidget): # ------------------------------------------------------------- # Workarea workfiles # ------------------------------------------------------------- - def _open_workfile(self, filepath): + def _open_workfile(self, folder_id, task_name, filepath): if self._controller.has_unsaved_changes(): result = self._save_changes_prompt() if result is None: @@ -186,12 +187,15 @@ class FilesWidget(QtWidgets.QWidget): if result: self._controller.save_current_workfile() - self._controller.open_workfile(filepath) + self._controller.open_workfile(folder_id, task_name, filepath) def _on_workarea_open_clicked(self): path = self._workarea_widget.get_selected_path() - if path: - self._open_workfile(path) + if not path: + return + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, path) def _on_current_open_requests(self): self._on_workarea_open_clicked() @@ -238,8 +242,12 @@ class FilesWidget(QtWidgets.QWidget): } filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] - if filepath: - self._open_workfile(filepath) + if not filepath: + return + + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, filepath) def _on_workarea_save_clicked(self): result = self._exec_save_as_dialog() @@ -279,10 +287,11 @@ class FilesWidget(QtWidgets.QWidget): def _on_task_changed(self, event): self._selected_folder_id = event["folder_id"] - self._selected_tak_name = event["task_name"] + self._selected_task_id = event["task_id"] + self._selected_task_name = event["task_name"] self._valid_selected_context = ( self._selected_folder_id is not None - and self._selected_tak_name is not None + and self._selected_task_id is not None ) self._update_published_btns_state() @@ -311,7 +320,7 @@ class FilesWidget(QtWidgets.QWidget): if enabled: self._pre_select_folder_id = self._selected_folder_id - self._pre_select_task_name = self._selected_tak_name + self._pre_select_task_name = self._selected_task_name else: self._pre_select_folder_id = None self._pre_select_task_name = None @@ -334,7 +343,7 @@ class FilesWidget(QtWidgets.QWidget): return True if self._pre_select_task_name is None: return False - return self._pre_select_task_name != self._selected_tak_name + return self._pre_select_task_name != self._selected_task_name def _on_published_cancel_clicked(self): folder_id = self._pre_select_folder_id From 01eb8ade9bc522012f1621741d5d89622456f420 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 17:04:04 +0200 Subject: [PATCH 167/327] fixing inventory management for version update --- openpype/hosts/resolve/api/lib.py | 20 +++++++++++++------ .../hosts/resolve/plugins/load/load_clip.py | 6 +++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index a88564a3ef..65c91fcdf6 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -395,14 +395,22 @@ def get_current_timeline_items( def get_pype_timeline_item_by_name(name: str) -> object: - track_itmes = get_current_timeline_items() - for _ti in track_itmes: - tag_data = get_timeline_item_pype_tag(_ti["clip"]["item"]) - tag_name = tag_data.get("name") + """Get timeline item by name. + + Args: + name (str): name of timeline item + + Returns: + object: resolve.TimelineItem + """ + for _ti_data in get_current_timeline_items(): + _ti_clip = _ti_data["clip"]["item"] + tag_data = get_timeline_item_pype_tag(_ti_clip) + tag_name = tag_data.get("namespace") if not tag_name: continue - if tag_data.get("name") in name: - return _ti + if tag_name in name: + return _ti_clip return None diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index 1d66c97041..eea44a3726 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -102,8 +102,8 @@ class LoadClip(plugin.TimelineItemLoader): context.update({"representation": representation}) name = container['name'] namespace = container['namespace'] - timeline_item_data = lib.get_pype_timeline_item_by_name(namespace) - timeline_item = timeline_item_data["clip"]["item"] + timeline_item = lib.get_pype_timeline_item_by_name(namespace) + project_name = get_current_project_name() version = get_version_by_id(project_name, representation["parent"]) version_data = version.get("data", {}) @@ -111,8 +111,8 @@ class LoadClip(plugin.TimelineItemLoader): colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) path = get_representation_path(representation) - context["version"] = {"data": version_data} + context["version"] = {"data": version_data} loader = plugin.ClipLoader(self, context, path) timeline_item = loader.update(timeline_item) From e0e8673bdacc0d1cc54f094c0631b548ced6a432 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 2 Oct 2023 16:20:38 +0100 Subject: [PATCH 168/327] Add Maya 2024 and remove pre 2022. --- .../system_settings/applications.json | 80 +++++-------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f2fc7d933a..66df1ab7d8 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -12,6 +12,26 @@ "LC_ALL": "C" }, "variants": { + "2024": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": { + "MAYA_VERSION": "2024" + } + }, "2023": { "use_python_2": false, "executables": { @@ -51,66 +71,6 @@ "environment": { "MAYA_VERSION": "2022" } - }, - "2020": { - "use_python_2": true, - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2020/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": { - "MAYA_VERSION": "2020" - } - }, - "2019": { - "use_python_2": true, - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2019/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": { - "MAYA_VERSION": "2019" - } - }, - "2018": { - "use_python_2": true, - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2018/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": { - "MAYA_VERSION": "2018" - } } } }, From 7206757c13e29d20f74d173b05327274077aa6c8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 18:18:08 +0200 Subject: [PATCH 169/327] Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id (#5668) * Refactor plugin data storage to be a dict by plugin.id + Fix `_current_plugin_data` type on `__init__` * Avoid plural when the plugin is singular * Refactor `_plugin_data_by_plugin_id` to `_plugin_data_by_id` --- openpype/tools/publisher/control.py | 56 ++++++++++++++--------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index e6b68906fd..a6264303d5 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -176,11 +176,10 @@ class PublishReportMaker: self._create_discover_result = None self._convert_discover_result = None self._publish_discover_result = None - self._plugin_data = [] - self._plugin_data_with_plugin = [] - self._stored_plugins = set() - self._current_plugin_data = [] + self._plugin_data_by_id = {} + self._current_plugin = None + self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = None @@ -192,9 +191,9 @@ class PublishReportMaker: create_context.convertor_discover_result ) self._publish_discover_result = create_context.publish_discover_result - self._plugin_data = [] - self._plugin_data_with_plugin = [] - self._stored_plugins = set() + + self._plugin_data_by_id = {} + self._current_plugin = None self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = context @@ -211,18 +210,11 @@ class PublishReportMaker: if self._current_plugin_data: self._current_plugin_data["passed"] = True + self._current_plugin = plugin self._current_plugin_data = self._add_plugin_data_item(plugin) - def _get_plugin_data_item(self, plugin): - store_item = None - for item in self._plugin_data_with_plugin: - if item["plugin"] is plugin: - store_item = item["data"] - break - return store_item - def _add_plugin_data_item(self, plugin): - if plugin in self._stored_plugins: + if plugin.id in self._plugin_data_by_id: # 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 @@ -230,15 +222,9 @@ class PublishReportMaker: raise ValueError( "Plugin '{}' is already stored".format(str(plugin))) - self._stored_plugins.add(plugin) - plugin_data_item = self._create_plugin_data_item(plugin) + self._plugin_data_by_id[plugin.id] = plugin_data_item - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) return plugin_data_item def _create_plugin_data_item(self, plugin): @@ -279,7 +265,7 @@ class PublishReportMaker: """Add result of single action.""" plugin = result["plugin"] - store_item = self._get_plugin_data_item(plugin) + store_item = self._plugin_data_by_id.get(plugin.id) if store_item is None: store_item = self._add_plugin_data_item(plugin) @@ -301,14 +287,24 @@ class PublishReportMaker: instance, instance in self._current_context ) - plugins_data = copy.deepcopy(self._plugin_data) - if plugins_data and not plugins_data[-1]["passed"]: - plugins_data[-1]["passed"] = True + plugins_data_by_id = copy.deepcopy( + self._plugin_data_by_id + ) + + # Ensure the current plug-in is marked as `passed` in the result + # so that it shows on reports for paused publishes + if self._current_plugin is not None: + current_plugin_data = plugins_data_by_id.get( + self._current_plugin.id + ) + if current_plugin_data and not current_plugin_data["passed"]: + current_plugin_data["passed"] = True if publish_plugins: for plugin in publish_plugins: - if plugin not in self._stored_plugins: - plugins_data.append(self._create_plugin_data_item(plugin)) + if plugin.id not in plugins_data_by_id: + plugins_data_by_id[plugin.id] = \ + self._create_plugin_data_item(plugin) reports = [] if self._create_discover_result is not None: @@ -329,7 +325,7 @@ class PublishReportMaker: ) return { - "plugins_data": plugins_data, + "plugins_data": list(plugins_data_by_id.values()), "instances": instances_details, "context": self._extract_context_data(self._current_context), "crashed_file_paths": crashed_file_paths, From 4330281688177556995c3acac1e72057c8a3bce5 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 13:53:30 +0800 Subject: [PATCH 170/327] small bugfix on collect skeleton mesh and minor tweak --- openpype/hosts/maya/api/lib.py | 8 ++--- .../plugins/publish/collect_skeleton_mesh.py | 34 ++++++++++--------- .../plugins/publish/extract_fbx_animation.py | 6 +++- .../publish/validate_animated_reference.py | 7 +++- .../plugins/publish/validate_rig_contents.py | 20 ++++++----- .../publish/validate_rig_controllers.py | 2 -- .../publish/validate_rig_out_set_node_ids.py | 5 --- .../publish/validate_rig_output_ids.py | 2 -- 8 files changed, 43 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index fb19cd64a6..f62463420e 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4150,11 +4150,9 @@ def create_rig_animation_instance( host = registered_host() create_context = CreateContext(host) # Create the animation instance - rig_sets = [output, controls] - if anim_skeleton: - rig_sets.append(anim_skeleton) - if skeleton_mesh: - rig_sets.append(skeleton_mesh) + rig_sets = [output, controls, anim_skeleton, skeleton_mesh] + # Remove sets that this particular rig does not have + rig_sets = [s for s in rig_sets if s is not None] with maintained_selection(): cmds.select(rig_sets + roots, noExpand=True) create_context.create( diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index 648029c3fc..b7849238ae 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -12,13 +12,11 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): families = ["rig"] def process(self, instance): - skeleton_mesh_sets = [ - i for i in instance - if i.lower().endswith("skeletonmesh_set") - ] - if not skeleton_mesh_sets: + skeleton_mesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeleton_mesh_set: self.log.debug( - "skeletonMesh_SET found. " + "No skeletonMesh_SET found. " "Skipping collecting of skeleton mesh..." ) return @@ -30,14 +28,18 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): instance.data["skeleton_mesh"] = [] - if skeleton_mesh_sets: + if skeleton_mesh_set: + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + if not skeleton_mesh_content: + self.log.debug( + "No object nodes in skeletonMesh_SET. " + "Skipping collecting of skeleton mesh..." + ) + return instance.data["families"] += ["rig.fbx"] - for skeleton_mesh_set in skeleton_mesh_sets: - skeleton_mesh_content = cmds.sets( - skeleton_mesh_set, query=True) - if skeleton_mesh_content: - instance.data["skeleton_mesh"] += skeleton_mesh_content - self.log.debug( - "Collected skeletonmesh Set: {}".format( - skeleton_mesh_content - )) + instance.data["skeleton_mesh"] = skeleton_mesh_content + self.log.debug( + "Collected skeletonMesh_SET members: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index f9e696489e..20352e1d8a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,7 +44,11 @@ class ExtractFBXAnimation(publish.Extractor): # FBX does not include the namespace but preserves the node # names as existing in the rig workfile namespace, relative_out_set = out_set_name.split(":", 1) - with namespaced(":" + namespace, new=False, relative_names=True) as namespace: # noqa + with namespaced( + ":" + namespace, + new=False, + relative_names=True + ) as namespace: fbx_exporter.export(relative_out_set, path) representations = instance.data.setdefault("representations", []) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index 3dc272d7cc..fe13561048 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -1,4 +1,5 @@ import pyblish.api +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder @@ -14,12 +15,15 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): families = ["animation.fbx"] label = "Animated Reference Rig" accepted_controllers = ["transform", "locator"] + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): animated_sets = instance.data["animated_skeleton"] if not animated_sets: self.log.debug( - "No nodes found in skeletonAnim_SET.Skipping...") + "No nodes found in skeletonAnim_SET. " + "Skipping validation of animated reference rig..." + ) return for animated_reference in animated_sets: @@ -37,6 +41,7 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): " should be transforms" ) + @classmethod def validate_controls(self, set_members): """Check if the controller set passes the validations diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index c63d0e0a2e..c1a1ce4ffa 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -1,6 +1,6 @@ import pyblish.api from maya import cmds - +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder @@ -20,6 +20,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): label = "Rig Contents" hosts = ["maya"] families = ["rig"] + action = [openpype.hosts.maya.api.action.SelectInvalidAction ] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] @@ -77,7 +78,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True - return error + if error: + return invalid_hierarchy + invalid_controls + invalid_geometry @classmethod def validate_missing_objectsets(cls, instance, @@ -103,7 +105,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def invalid_hierarchy(cls, instance, content): - """_summary_ + """Check if the sets passes the validation Args: instance (str): instance @@ -192,7 +194,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): instance (str): instance Returns: - list: list of objectsets, list of rig sets nodes + tuple: 2-tuple of list of objectsets, + list of rig sets nodes """ objectsets = ["controls_SET", "out_SET"] rig_sets_nodes = instance.data.get("rig_sets", []) @@ -213,8 +216,6 @@ class ValidateSkeletonRigContents(ValidateRigContents): hosts = ["maya"] families = ["rig.fbx"] - accepted_output = {"mesh", "transform", "locator"} - @classmethod def get_invalid(cls, instance): objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) @@ -240,7 +241,8 @@ class ValidateSkeletonRigContents(ValidateRigContents): "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True - return error + if error: + return invalid_hierarchy + invalid_geometry @classmethod def get_nodes(cls, instance): @@ -250,8 +252,8 @@ class ValidateSkeletonRigContents(ValidateRigContents): instance (str): instance Returns: - list: list of objectsets, - list of objects node from skeletonMesh_SET + tuple: 2-tuple of list of objectsets, + list of rig sets nodes """ objectsets = ["skeletonMesh_SET"] skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index a10e2158fa..82248c57b3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -264,8 +264,6 @@ class ValidateSkeletonRigControllers(ValidateRigControllers): label = "Skeleton Rig Controllers" hosts = ["maya"] families = ["rig.fbx"] - actions = [RepairAction, - openpype.hosts.maya.api.action.SelectInvalidAction] # Default controller values CONTROLLER_DEFAULTS = { diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 6f713a3ca1..80ac0f27e6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -114,11 +114,6 @@ class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): families = ["rig.fbx"] hosts = ['maya'] label = 'Skeleton Rig Out Set Node Ids' - actions = [ - openpype.hosts.maya.api.action.SelectInvalidAction, - RepairAction - ] - allow_history_only = False @classmethod def get_node(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index ec46b2be87..343d8e6924 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -140,8 +140,6 @@ class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): label = "Skeleton Rig Output Ids" hosts = ["maya"] families = ["rig.fbx"] - actions = [RepairAction, - openpype.hosts.maya.api.action.SelectInvalidAction] @classmethod def get_node(cls, instance): From a49cacc74f4c89a4a70da4bfa8a6d2c0bf458d0a Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 13:54:37 +0800 Subject: [PATCH 171/327] hound --- openpype/hosts/maya/plugins/publish/validate_rig_contents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index c1a1ce4ffa..963ebcea83 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -20,7 +20,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): label = "Rig Contents" hosts = ["maya"] families = ["rig"] - action = [openpype.hosts.maya.api.action.SelectInvalidAction ] + action = [openpype.hosts.maya.api.action.SelectInvalidAction] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] From ae1c98d10cc57d24252b373040a904772dc75ba4 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 13:56:23 +0800 Subject: [PATCH 172/327] docstring edit for invalid hierarchy in validate rig content --- openpype/hosts/maya/plugins/publish/validate_rig_contents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 963ebcea83..f55365cc54 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -105,7 +105,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def invalid_hierarchy(cls, instance, content): - """Check if the sets passes the validation + """Check if the rig sets passes the validation with + correct hierarchy Args: instance (str): instance From 7cbe5e8f6259fae8134a108799a73a64ceb0a61a Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 15:39:48 +0800 Subject: [PATCH 173/327] docstring tweak and some code twek --- .../plugins/publish/collect_fbx_animation.py | 2 +- .../plugins/publish/collect_skeleton_mesh.py | 27 +++++++++---------- .../publish/validate_animated_reference.py | 2 +- .../plugins/publish/validate_rig_contents.py | 13 ++++----- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index ee5ac741c8..03a54af08a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -19,7 +19,7 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin, return skeleton_sets = [ i for i in instance - if i.lower().endswith("skeletonanim_set") + if i.endswith("skeletonAnim_SET") ] if not skeleton_sets: return diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py index b7849238ae..31f0eca88c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -28,18 +28,17 @@ class CollectSkeletonMesh(pyblish.api.InstancePlugin): instance.data["skeleton_mesh"] = [] - if skeleton_mesh_set: - skeleton_mesh_content = cmds.sets( - skeleton_mesh_set, query=True) or [] - if not skeleton_mesh_content: - self.log.debug( - "No object nodes in skeletonMesh_SET. " - "Skipping collecting of skeleton mesh..." - ) - return - instance.data["families"] += ["rig.fbx"] - instance.data["skeleton_mesh"] = skeleton_mesh_content + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + if not skeleton_mesh_content: self.log.debug( - "Collected skeletonMesh_SET members: {}".format( - skeleton_mesh_content - )) + "No object nodes in skeletonMesh_SET. " + "Skipping collecting of skeleton mesh..." + ) + return + instance.data["families"] += ["rig.fbx"] + instance.data["skeleton_mesh"] = skeleton_mesh_content + self.log.debug( + "Collected skeletonMesh_SET members: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index fe13561048..dd606ceaef 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -43,7 +43,7 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): @classmethod def validate_controls(self, set_members): - """Check if the controller set passes the validations + """Check if the controller set contains only accepted node types. Checks if all its set members are within the hierarchy of the root Checks if the node types of the set members valid diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index f55365cc54..106b4024e2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -105,8 +105,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def invalid_hierarchy(cls, instance, content): - """Check if the rig sets passes the validation with - correct hierarchy + """ + Check if all rig set members are within the hierarchy of the rig root Args: instance (str): instance @@ -140,9 +140,7 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def validate_geometry(cls, set_members): - """Check if the out set passes the validations - - Checks if all its set members are within the hierarchy of the root + """ Checks if the node types of the set members valid Args: @@ -166,9 +164,8 @@ class ValidateRigContents(pyblish.api.InstancePlugin): @classmethod def validate_controls(cls, set_members): - """Check if the controller set passes the validations - - Checks if all its set members are within the hierarchy of the root + """ + Checks if the control set members are allowed node types. Checks if the node types of the set members valid Args: From 308d54ba267fc9ae767868a766eb7372ce9f0f8b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 3 Oct 2023 08:48:51 +0100 Subject: [PATCH 174/327] Ayon settings --- .../applications/server/applications.json | 80 +++++-------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index 8e5b28623e..a8daf79f7b 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -7,6 +7,26 @@ "host_name": "maya", "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n", "variants": [ + { + "name": "2024", + "label": "2024", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}", + "use_python_2": false + }, { "name": "2023", "label": "2023", @@ -45,66 +65,6 @@ "linux": [] }, "environment": "{\n \"MAYA_VERSION\": \"2022\"\n}", - "use_python_2": false - }, - { - "name": "2020", - "label": "2020", - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2020/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": "{\n \"MAYA_VERSION\": \"2020\"\n}", - "use_python_2": true - }, - { - "name": "2019", - "label": "2019", - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2019/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": "{\n \"MAYA_VERSION\": \"2019\"\n}", - "use_python_2": true - }, - { - "name": "2018", - "label": "2018", - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2018/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": "{\n \"MAYA_VERSION\": \"2018\"\n}", "use_python_2": true } ] From a5b85d36f0d8e8b534d3016c8358e5be45604661 Mon Sep 17 00:00:00 2001 From: Ember Light <49758407+EmberLightVFX@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:35:50 +0200 Subject: [PATCH 175/327] Removed double space in end of file Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/fusion/plugins/create/create_saver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index edac113e85..21711f0229 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -247,7 +247,6 @@ class CreateSaver(NewCreator): label="Review", ) - def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" From c5b9667aa292584c38e03f538c876a44cb31ad03 Mon Sep 17 00:00:00 2001 From: Ember Light <49758407+EmberLightVFX@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:36:17 +0200 Subject: [PATCH 176/327] Place get_frame_path import on one row Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/fusion/plugins/publish/collect_render.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index 5474b677cf..a7daa0b64c 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -4,9 +4,7 @@ import pyblish.api from openpype.pipeline import publish from openpype.pipeline.publish import RenderInstance -from openpype.hosts.fusion.api.lib import ( - get_frame_path, -) +from openpype.hosts.fusion.api.lib import get_frame_path @attr.s From 3d2b0172859a8d5b5ab9d5e287bd38e8f6528311 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 17:32:58 +0800 Subject: [PATCH 177/327] minor tweak --- openpype/hosts/maya/plugins/publish/collect_fbx_animation.py | 2 +- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 3 +-- .../hosts/maya/plugins/publish/validate_animated_reference.py | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py index 03a54af08a..aef8765e9c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -33,4 +33,4 @@ class CollectFbxAnimation(pyblish.api.InstancePlugin, skeleton_content )) if skeleton_content: - instance.data["animated_skeleton"] += skeleton_content + instance.data["animated_skeleton"] = skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 20352e1d8a..27be724ec0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -39,11 +39,10 @@ class ExtractFBXAnimation(publish.Extractor): fbx_exporter.set_options_from_instance(instance) - out_set_name = next(out for out in out_set) # Export from the rig's namespace so that the exported # FBX does not include the namespace but preserves the node # names as existing in the rig workfile - namespace, relative_out_set = out_set_name.split(":", 1) + namespace, relative_out_set = out_set[0].split(":", 1) with namespaced( ":" + namespace, new=False, diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py index dd606ceaef..4537892d6d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -18,7 +18,7 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): actions = [openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): - animated_sets = instance.data["animated_skeleton"] + animated_sets = instance.data.get("animated_skeleton", []) if not animated_sets: self.log.debug( "No nodes found in skeletonAnim_SET. " @@ -58,6 +58,7 @@ class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): # Validate control types invalid = [] + set_members = cmds.ls(set_members, long=True) for node in set_members: if cmds.nodeType(node) not in self.accepted_controllers: invalid.append(node) From 12be0186b0b634fb60ee87cb0c9250a410d39b02 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 17:40:38 +0800 Subject: [PATCH 178/327] minor tweak --- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 27be724ec0..8036c799e7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -31,7 +31,7 @@ class ExtractFBXAnimation(publish.Extractor): path = path.replace("\\", "/") fbx_exporter = fbx.FBXExtractor(log=self.log) - out_set = instance.data.get("animated_skeleton", []) + out_group = instance.data.get("animated_skeleton", []) # Export instance.data["constraints"] = True instance.data["skeletonDefinitions"] = True @@ -42,13 +42,13 @@ class ExtractFBXAnimation(publish.Extractor): # Export from the rig's namespace so that the exported # FBX does not include the namespace but preserves the node # names as existing in the rig workfile - namespace, relative_out_set = out_set[0].split(":", 1) + namespace, relative_out_group = out_group[0].split(":", 1) with namespaced( ":" + namespace, new=False, relative_names=True ) as namespace: - fbx_exporter.export(relative_out_set, path) + fbx_exporter.export(relative_out_group, path) representations = instance.data.setdefault("representations", []) representations.append({ From 7f5be3d61ad6b09ca29123f4b3cef2496e03787e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 3 Oct 2023 10:42:16 +0100 Subject: [PATCH 179/327] Fix remove/update in new layout instance --- openpype/hosts/blender/plugins/load/load_blend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index fa41f4374b..25d6568889 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -244,7 +244,7 @@ class BlendLoader(plugin.AssetLoader): for parent in parent_containers: parent.get(AVALON_PROPERTY)["members"] = list(filter( lambda i: i not in members, - parent.get(AVALON_PROPERTY)["members"])) + parent.get(AVALON_PROPERTY).get("members", []))) for attr in attrs: for data in getattr(bpy.data, attr): From f0b38dbb9a830d4475cdc34d87f8b5bd2d245645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 3 Oct 2023 12:49:37 +0200 Subject: [PATCH 180/327] Update openpype/hosts/resolve/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/resolve/api/lib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 65c91fcdf6..92e600d55b 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -128,6 +128,9 @@ def get_any_timeline(): def get_new_timeline(timeline_name: str = None): """Get new timeline object. +Arguments: + timeline_name (str): New timeline name. + Returns: object: resolve.Timeline """ From 4bd820bc30299b018c5f21d5f1b659045c360279 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 Oct 2023 13:00:49 +0200 Subject: [PATCH 181/327] removing attachmets from self and moving into `timeline_basename` --- openpype/hosts/resolve/api/lib.py | 4 ++-- openpype/hosts/resolve/api/plugin.py | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 92e600d55b..9bdd62d52e 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -128,8 +128,8 @@ def get_any_timeline(): def get_new_timeline(timeline_name: str = None): """Get new timeline object. -Arguments: - timeline_name (str): New timeline name. + Arguments: + timeline_name (str): New timeline name. Returns: object: resolve.Timeline diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index ddf0df662b..1fc3ed226c 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -329,9 +329,8 @@ class ClipLoader: else: # create new sequence self.active_timeline = lib.get_new_timeline( - "{}_{}_{}".format( - self.subset, - self.representation, + "{}_{}".format( + self.data["timeline_basename"], str(uuid.uuid4())[:8] ) ) @@ -355,13 +354,13 @@ class ClipLoader: # create name repr = self.context["representation"] repr_cntx = repr["context"] - self.asset = str(repr_cntx["asset"]) - self.subset = str(repr_cntx["subset"]) - self.representation = str(repr_cntx["representation"]) + asset = str(repr_cntx["asset"]) + subset = str(repr_cntx["subset"]) + representation = str(repr_cntx["representation"]) self.data["clip_name"] = "_".join([ - self.asset, - self.subset, - self.representation + asset, + subset, + representation ]) self.data["versionData"] = self.context["version"]["data"] # gets file path @@ -372,12 +371,14 @@ class ClipLoader: "Representation id `{}` is failing to load".format(repr_id)) return None self.data["path"] = file.replace("\\", "/") + self.data["timeline_basename"] = "timeline_{}_{}".format( + subset, representation) # solve project bin structure path hierarchy = str("/".join(( "Loader", repr_cntx["hierarchy"].replace("\\", "/"), - self.asset + asset ))) self.data["binPath"] = hierarchy From f5e8f4d3faf50f3da237702dd2b26c2cdb24ef40 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 Oct 2023 13:02:17 +0200 Subject: [PATCH 182/327] removing debugging prints --- openpype/hosts/resolve/api/plugin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 1fc3ed226c..da5e649576 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -336,7 +336,6 @@ class ClipLoader: ) loader_cls.timeline = self.active_timeline - print(self.active_timeline.GetName()) else: self.active_timeline = lib.get_current_timeline() @@ -660,8 +659,6 @@ class PublishClip: # define ui inputs if non gui mode was used self.shot_num = self.ti_index - print( - "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( From 4656e59759ad0ae6ac31564ece150802759779b2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 Oct 2023 13:10:51 +0200 Subject: [PATCH 183/327] debug logging cleaning --- openpype/hosts/resolve/api/lib.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 9bdd62d52e..735d2057f8 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -281,7 +281,6 @@ def create_timeline_item(media_pool_item: object, if source_end is not None: clip_data.update({"endFrame": source_end}) - print(clip_data) # add to timeline media_pool.AppendToTimeline([clip_data]) @@ -560,7 +559,6 @@ def get_pype_marker(timeline_item): note = timeline_item_markers[marker_frame]["note"] color = timeline_item_markers[marker_frame]["color"] name = timeline_item_markers[marker_frame]["name"] - print(f"_ marker data: {marker_frame} | {name} | {color} | {note}") if name == self.pype_marker_name and color == self.pype_marker_color: self.temp_marker_frame = marker_frame return json.loads(note) @@ -630,7 +628,7 @@ def create_compound_clip(clip_data, name, folder): if c.GetName() in name), None) if cct: - print(f"_ cct exists: {cct}") + print(f"Compound clip exists: {cct}") else: # Create empty timeline in current folder and give name: cct = mp.CreateEmptyTimeline(name) @@ -639,7 +637,7 @@ def create_compound_clip(clip_data, name, folder): clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) - print(f"_ cct created: {cct}") + print(f"Compound clip created: {cct}") with maintain_current_timeline(cct, tl_origin): # Add input clip to the current timeline: From 71838b05153576235b969e915ac716fac88ce97a Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 20:06:14 +0800 Subject: [PATCH 184/327] abstract relativeNames namesapces into function --- openpype/hosts/maya/api/lib.py | 41 +++++++++++++++++++ .../plugins/publish/extract_fbx_animation.py | 15 ++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index f62463420e..1923a008d5 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -183,6 +183,47 @@ def maintained_selection(): cmds.select(clear=True) +def get_namespace(node): + """Return namespace of given node""" + return node.rsplit("|", 1)[-1].rsplit(":", 1)[0] + + +def strip_namespace(node, namespace): + """Strip given namespace from node path. + + The namespace will only be stripped from names + if it starts with that namespace. If the namespace + occurs within another namespace it's not removed. + + Examples: + >>> strip_namespace("namespace:node", namespace="namespace:") + "node" + >>> strip_namespace("hello:world:node", namespace="hello:world") + "node" + >>> strip_namespace("hello:world:node", namespace="hello") + "world:node" + >>> strip_namespace("hello:world:node", namespace="world") + "hello:world:node" + >>> strip_namespace("ns:group|ns:node", namespace="ns") + "group|node" + + Returns: + str: Node name without given starting namespace. + + """ + + # Ensure namespace ends with `:` + if not namespace.endswith(":"): + namespace = "{}:".format(namespace) + + # The long path for a node can also have the namespace + # in its parents so we need to remove it from each + return "|".join( + name[len(namespace):] if name.startswith(namespace) else name + for name in node.split("|") + ) + + def get_custom_namespace(custom_namespace): """Return unique namespace. diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 8036c799e7..8288bc9329 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -6,7 +6,9 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import fbx -from openpype.hosts.maya.api.lib import namespaced +from openpype.hosts.maya.api.lib import ( + namespaced, get_namespace, strip_namespace +) class ExtractFBXAnimation(publish.Extractor): @@ -31,24 +33,25 @@ class ExtractFBXAnimation(publish.Extractor): path = path.replace("\\", "/") fbx_exporter = fbx.FBXExtractor(log=self.log) - out_group = instance.data.get("animated_skeleton", []) + out_members = instance.data.get("animated_skeleton", []) # Export instance.data["constraints"] = True instance.data["skeletonDefinitions"] = True instance.data["referencedAssetsContent"] = True - fbx_exporter.set_options_from_instance(instance) - # Export from the rig's namespace so that the exported # FBX does not include the namespace but preserves the node # names as existing in the rig workfile - namespace, relative_out_group = out_group[0].split(":", 1) + namespace = get_namespace(out_members[0]) + relative_out_members = [ + strip_namespace(node, namespace) for node in out_members + ] with namespaced( ":" + namespace, new=False, relative_names=True ) as namespace: - fbx_exporter.export(relative_out_group, path) + fbx_exporter.export(relative_out_members, path) representations = instance.data.setdefault("representations", []) representations.append({ From 8b16bacb5315377f7a6f2539b838ea32da0bacf6 Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 20:21:37 +0800 Subject: [PATCH 185/327] make sure the get_namespace won't error out if it doesn't get anything --- openpype/hosts/maya/api/lib.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 1923a008d5..510d4ecc85 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -185,7 +185,11 @@ def maintained_selection(): def get_namespace(node): """Return namespace of given node""" - return node.rsplit("|", 1)[-1].rsplit(":", 1)[0] + node_name = node.rsplit("|", 1)[-1] + if ":" in node_name: + return node_name.rsplit(":", 1)[0] + else: + return "" def strip_namespace(node, namespace): From 56aa22af17be49e18e939f43407eab31338071af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 3 Oct 2023 16:37:51 +0200 Subject: [PATCH 186/327] :bug: fix variable name overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations --- .../plugins/publish/validate_plugin_path_attributes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 9f47bf7a3d..3974150a10 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -30,18 +30,18 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): def get_invalid(cls, instance): invalid = list() - file_attr = cls.attribute - if not file_attr: + file_attrs = cls.attribute + if not file_attrs: return invalid # Consider only valid node types to avoid "Unknown object type" warning all_node_types = set(cmds.allNodeTypes()) - node_types = [key for key in file_attr.keys() if key in all_node_types] + node_types = [key for key in file_attrs.keys() if key in all_node_types] for node, node_type in pairwise(cmds.ls(type=node_types, showType=True)): # get the filepath - file_attr = "{}.{}".format(node, file_attr[node_type]) + file_attr = "{}.{}".format(node, file_attrs[node_type]) filepath = cmds.getAttr(file_attr) if filepath and not os.path.exists(filepath): From 1f265f064a17c4a7befda74ea0cb10ac67b92e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 3 Oct 2023 16:40:54 +0200 Subject: [PATCH 187/327] :dog: fix hound --- .../maya/plugins/publish/validate_plugin_path_attributes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 3974150a10..cb5c68e4ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -36,7 +36,10 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): # Consider only valid node types to avoid "Unknown object type" warning all_node_types = set(cmds.allNodeTypes()) - node_types = [key for key in file_attrs.keys() if key in all_node_types] + node_types = [ + key for key in file_attrs.keys() + if key in all_node_types + ] for node, node_type in pairwise(cmds.ls(type=node_types, showType=True)): From e78b6065acac274bb3655c60f8a6081372338d8b Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 3 Oct 2023 18:18:02 +0100 Subject: [PATCH 188/327] Add openpype_mongo command flag for testing. (#5676) * Add openpype_mongo command flag for testing. * Revert back to TEST_OPENPYPE_MONGO TEST_OPENPYPE_MONGO is placeholder used in all source test sip in `input/env_vars/env_var` not a env variable itself * Fix openpype_mongo fixture Fixture decorator was missing. If value passed from command line should be used, it must come first as `env_var` fixture should already contain valid default Mongo uri. * Renamed command line argument to mongo_url --------- Co-authored-by: kalisp --- openpype/cli.py | 8 ++++++-- openpype/pype_commands.py | 16 +++++++++++++++- tests/conftest.py | 10 ++++++++++ tests/lib/testing_classes.py | 4 ++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 0df277fb0a..7422f32f13 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -290,11 +290,15 @@ def run(script): "--setup_only", help="Only create dbs, do not run tests", default=None) +@click.option("--mongo_url", + help="MongoDB for testing.", + default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only): + timeout, setup_only, mongo_url): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant, timeout, setup_only) + persist, app_variant, timeout, setup_only, + mongo_url) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 7f1c3b01e2..7adebbbc97 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -213,7 +213,8 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant, timeout, setup_only): + test_data_folder, persist, app_variant, timeout, setup_only, + mongo_url): """ Runs tests from 'folder' @@ -226,6 +227,10 @@ class PypeCommands: end app_variant (str): variant (eg 2020 for AE), empty if use latest installed version + timeout (int): explicit timeout for single test + setup_only (bool): if only preparation steps should be + triggered, no tests (useful for debugging/development) + mongo_url (str): url to Openpype Mongo database """ print("run_tests") if folder: @@ -264,6 +269,15 @@ class PypeCommands: if setup_only: args.extend(["--setup_only", setup_only]) + if mongo_url: + args.extend(["--mongo_url", mongo_url]) + else: + msg = ( + "Either provide uri to MongoDB through environment variable" + " OPENPYPE_MONGO or the command flag --mongo_url" + ) + assert not os.environ.get("OPENPYPE_MONGO"), msg + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/tests/conftest.py b/tests/conftest.py index 4f7c17244b..6e82c9917d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,6 +29,11 @@ def pytest_addoption(parser): help="True - only setup test, do not run any tests" ) + parser.addoption( + "--mongo_url", action="store", default=None, + help="Provide url of the Mongo database." + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -55,6 +60,11 @@ def setup_only(request): return request.config.getoption("--setup_only") +@pytest.fixture(scope="module") +def mongo_url(request): + return request.config.getoption("--mongo_url") + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 2af4af02de..e82e438e54 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -147,11 +147,11 @@ class ModuleUnitTest(BaseTest): @pytest.fixture(scope="module") def db_setup(self, download_test_data, env_var, monkeypatch_session, - request): + request, mongo_url): """Restore prepared MongoDB dumps into selected DB.""" backup_dir = os.path.join(download_test_data, "input", "dumps") - uri = os.environ.get("OPENPYPE_MONGO") + uri = mongo_url or os.environ.get("OPENPYPE_MONGO") db_handler = DBHandler(uri) db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, overwrite=True, From 02b64a40f1f0181c684fee182a72f723c680bb34 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 4 Oct 2023 03:24:55 +0000 Subject: [PATCH 189/327] [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 8234258f19..399c1404b1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2-nightly.1" +__version__ = "3.17.2-nightly.2" From 4a2417d2ca741c6b5a8db81b042e0bfff7a4d12a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Oct 2023 03:25:31 +0000 Subject: [PATCH 190/327] 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 9fb7bbc66c..e3ca8262e5 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.17.2-nightly.2 - 3.17.2-nightly.1 - 3.17.1 - 3.17.1-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.9 - 3.14.10-nightly.8 - 3.14.10-nightly.7 - - 3.14.10-nightly.6 validations: required: true - type: dropdown From 32b4fc5f645c638a9d11b3e00a4283a80a865b17 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 16:10:19 +0800 Subject: [PATCH 191/327] add resolution validator for render instance in maya --- .../plugins/publish/validate_resolution.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/validate_resolution.py diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py new file mode 100644 index 0000000000..4c350388e2 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -0,0 +1,54 @@ +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from maya import cmds +from openpype.hosts.maya.api.lib import reset_scene_resolution + + +class ValidateResolution(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate the resolution setting aligned with DB""" + + order = pyblish.api.ValidatorOrder - 0.01 + families = ["renderlayer"] + hosts = ["maya"] + label = "Validate Resolution" + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + width, height = self.get_db_resolution(instance) + current_width = cmds.getAttr("defaultResolution.width") + current_height = cmds.getAttr("defaultResolution.height") + if current_width != width and current_height != height: + raise PublishValidationError("Resolution Setting " + "not matching resolution " + "set on asset or shot.") + if current_width != width: + raise PublishValidationError("Width in Resolution Setting " + "not matching resolution set " + "on asset or shot.") + + if current_height != height: + raise PublishValidationError("Height in Resolution Setting " + "not matching resolution set " + "on asset or shot.") + + def get_db_resolution(self, instance): + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if "resolutionWidth" in data and "resolutionHeight" in data: + width = data["resolutionWidth"] + height = data["resolutionHeight"] + return int(width), int(height) + + # Defaults if not found in asset document or project document + return 1920, 1080 + + @classmethod + def repair(cls, instance): + return reset_scene_resolution() From c5bf50a4541a4c5ddfb1d64bd51b1654abf4cbe5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 17:12:28 +0800 Subject: [PATCH 192/327] minor docstring and code tweaks for ExtractReviewMov --- openpype/hosts/nuke/api/lib.py | 12 +++++------- openpype/hosts/nuke/api/plugin.py | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 07f394ec00..380d9a42d1 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3425,17 +3425,15 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): return "{} ({})".format(viewer, display) -def get_head_filename_without_hashes(original_path, name): - """Function to get the renamed head filename without frame hashes - To avoid the system being confused on finding the filename with - frame hashes if the head of the filename has the hashed symbol +def prepend_name_before_hashed_frame(original_path, name): + """Function to prepend an extra name before the hashed frame numbers Examples: - >>> get_head_filename_without_hashes("render.####.exr", "baking") + >>> prepend_name_before_hashed_frame("render.####.exr", "baking") render.baking.####.exr - >>> get_head_filename_without_hashes("render.%04d.exr", "tag") + >>> prepend_name_before_hashed_frame("render.%04d.exr", "tag") render.tag.%d.exr - >>> get_head_filename_without_hashes("exr.####.exr", "foo") + >>> prepend_name_before_hashed_frame("exr.####.exr", "foo") exr.foo.%04d.exr Args: diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 81841d17be..2f432ad9b6 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -39,7 +39,7 @@ from .lib import ( get_view_process_node, get_viewer_config_from_string, deprecated, - get_head_filename_without_hashes, + prepend_name_before_hashed_frame, get_filenames_without_hash ) from .pipeline import ( @@ -820,12 +820,12 @@ class ExporterReviewMov(ExporterReview): if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: # filename would be with frame hashes if # the file extension is not in video format - filename = get_head_filename_without_hashes( + filename = prepend_name_before_hashed_frame( self.path_in, self.name) self.file = filename # make sure the filename are in # correct image output format - if ".{}".format(self.ext) not in self.file: + if not self.file.endswith(".{}".format(ext)): filename_no_ext, _ = os.path.splitext(filename) self.file = "{}.{}".format(filename_no_ext, self.ext) From 090f1e041b14bfaa6e903f3f1fe836f84acb4253 Mon Sep 17 00:00:00 2001 From: Claudio Hickstein <122775550+spmhickstein@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:17:22 +0200 Subject: [PATCH 193/327] Deadline: handle all valid paths in RenderExecutable (#5694) * handle all valid paths in RenderExecutable * Update openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Petr Kalis Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../modules/deadline/repository/custom/plugins/Ayon/Ayon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index a29acf9823..2c55e7c951 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -96,7 +96,7 @@ class AyonDeadlinePlugin(DeadlinePlugin): for path in exe_list.split(";"): if path.startswith("~"): path = os.path.expanduser(path) - expanded_paths.append(path) + expanded_paths.append(path) exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": From aef56b7cd3c89a39bdb0975534d3a78a1c307133 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 18:52:45 +0800 Subject: [PATCH 194/327] remove unnecessary function --- openpype/hosts/nuke/api/lib.py | 26 -------------------------- openpype/hosts/nuke/api/plugin.py | 12 +++++------- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 380d9a42d1..390545b806 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3425,32 +3425,6 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): return "{} ({})".format(viewer, display) -def prepend_name_before_hashed_frame(original_path, name): - """Function to prepend an extra name before the hashed frame numbers - - Examples: - >>> prepend_name_before_hashed_frame("render.####.exr", "baking") - render.baking.####.exr - >>> prepend_name_before_hashed_frame("render.%04d.exr", "tag") - render.tag.%d.exr - >>> prepend_name_before_hashed_frame("exr.####.exr", "foo") - exr.foo.%04d.exr - - Args: - original_path (str): the filename with frame hashes - name (str): the name of the tags - - Returns: - str: the renamed filename with the tag - """ - filename = os.path.basename(original_path) - - def insert_name(matchobj): - return "{}.{}".format(name, matchobj.group(0)) - - return re.sub(r"(%\d*d)|#+", insert_name, filename) - - def get_filenames_without_hash(filename, frame_start, frame_end): """Get filenames without frame hash i.e. "renderCompositingMain.baking.0001.exr" diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 2f432ad9b6..2ce41f61c7 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -39,7 +39,6 @@ from .lib import ( get_view_process_node, get_viewer_config_from_string, deprecated, - prepend_name_before_hashed_frame, get_filenames_without_hash ) from .pipeline import ( @@ -818,15 +817,14 @@ class ExporterReviewMov(ExporterReview): self.file = self.fhead + self.name + ".{}".format(self.ext) if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: - # filename would be with frame hashes if - # the file extension is not in video format - filename = prepend_name_before_hashed_frame( - self.path_in, self.name) - self.file = filename + filename = os.path.basename(self.path_in) + self.file = self.fhead + self.name + ".{}".format( + filename.split(".", 1)[-1] + ) # make sure the filename are in # correct image output format if not self.file.endswith(".{}".format(ext)): - filename_no_ext, _ = os.path.splitext(filename) + filename_no_ext, _ = os.path.splitext(self.file) self.file = "{}.{}".format(filename_no_ext, self.ext) self.path = os.path.join( From 13b46070fe9fef0b6474a50e9cebb7d7b73eac43 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 19:32:41 +0800 Subject: [PATCH 195/327] use re.sub in the function for review frame sequence name --- openpype/hosts/nuke/api/plugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 2ce41f61c7..d54967aa15 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -818,9 +818,8 @@ class ExporterReviewMov(ExporterReview): self.file = self.fhead + self.name + ".{}".format(self.ext) if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: filename = os.path.basename(self.path_in) - self.file = self.fhead + self.name + ".{}".format( - filename.split(".", 1)[-1] - ) + self.file = re.sub( + self.fhead, self.fhead + self.name + ".", filename) # make sure the filename are in # correct image output format if not self.file.endswith(".{}".format(ext)): From 253c895363d2ec1fb0dcdc6d40d4539b120cefd6 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 21:48:44 +0800 Subject: [PATCH 196/327] clean up the code for implementating variable for self.file when the self.ext is image format --- openpype/hosts/nuke/api/plugin.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index d54967aa15..067814679c 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -817,15 +817,11 @@ class ExporterReviewMov(ExporterReview): self.file = self.fhead + self.name + ".{}".format(self.ext) if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: - filename = os.path.basename(self.path_in) - self.file = re.sub( - self.fhead, self.fhead + self.name + ".", filename) - # make sure the filename are in - # correct image output format - if not self.file.endswith(".{}".format(ext)): - filename_no_ext, _ = os.path.splitext(self.file) - self.file = "{}.{}".format(filename_no_ext, self.ext) - + filename_no_ext = os.path.splitext( + os.path.basename(self.path_in))[0] + after_head = filename_no_ext[len(self.fhead):] + self.file = "{}{}.{}.{}".format( + self.fhead, self.name, after_head, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") From 22ac8e7ac6fcece2aa73b066c02008b34bd6afff Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 22:08:33 +0800 Subject: [PATCH 197/327] use fomrat string for self.file --- openpype/hosts/nuke/api/plugin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 067814679c..0da181908e 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -815,8 +815,10 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") - self.file = self.fhead + self.name + ".{}".format(self.ext) - if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: + if ".{}".format(self.ext) in VIDEO_EXTENSIONS: + self.file = "{}{}.{}".format( + self.fhead, self.name, self.ext) + else: filename_no_ext = os.path.splitext( os.path.basename(self.path_in))[0] after_head = filename_no_ext[len(self.fhead):] From a75e5d8db6aad7c0462af8f1637080f8c619ca0e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 22:09:24 +0800 Subject: [PATCH 198/327] add comments --- openpype/hosts/nuke/api/plugin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 0da181908e..c39e3c339d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -819,6 +819,11 @@ class ExporterReviewMov(ExporterReview): self.file = "{}{}.{}".format( self.fhead, self.name, self.ext) else: + # Output is image (or image sequence) + # When the file is an image it's possible it + # has extra information after the `fhead` that + # we want to preserve, e.g. like frame numbers + # or frames hashes like `####` filename_no_ext = os.path.splitext( os.path.basename(self.path_in))[0] after_head = filename_no_ext[len(self.fhead):] From bf15868fc4cd0a75fe6017ddbdf316beb76af303 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 4 Oct 2023 17:48:16 +0200 Subject: [PATCH 199/327] Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost --- openpype/hosts/resolve/api/__init__.py | 11 +-- openpype/hosts/resolve/api/menu.py | 9 +- openpype/hosts/resolve/api/pipeline.py | 95 ++++++++++--------- openpype/hosts/resolve/api/utils.py | 10 +- openpype/hosts/resolve/startup.py | 9 +- .../resolve/utility_scripts/OpenPype__Menu.py | 7 +- 6 files changed, 71 insertions(+), 70 deletions(-) diff --git a/openpype/hosts/resolve/api/__init__.py b/openpype/hosts/resolve/api/__init__.py index 2b4546f8d6..dba275e6c4 100644 --- a/openpype/hosts/resolve/api/__init__.py +++ b/openpype/hosts/resolve/api/__init__.py @@ -6,13 +6,10 @@ from .utils import ( ) from .pipeline import ( - install, - uninstall, + ResolveHost, ls, containerise, update_container, - publish, - launch_workfiles_app, maintained_selection, remove_instance, list_instances @@ -76,14 +73,10 @@ __all__ = [ "bmdvf", # pipeline - "install", - "uninstall", + "ResolveHost", "ls", "containerise", "update_container", - "reload_pipeline", - "publish", - "launch_workfiles_app", "maintained_selection", "remove_instance", "list_instances", diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index b3717e01ea..34a63eb89f 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -5,11 +5,6 @@ from qtpy import QtWidgets, QtCore from openpype.tools.utils import host_tools -from .pipeline import ( - publish, - launch_workfiles_app -) - def load_stylesheet(): path = os.path.join(os.path.dirname(__file__), "menu_style.qss") @@ -113,7 +108,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_workfile_clicked(self): print("Clicked Workfile") - launch_workfiles_app() + host_tools.show_workfiles() def on_create_clicked(self): print("Clicked Create") @@ -121,7 +116,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_publish_clicked(self): print("Clicked Publish") - publish(None) + host_tools.show_publish(parent=None) def on_load_clicked(self): print("Clicked Load") diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 899cb825bb..19c7b13371 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -12,14 +12,24 @@ from openpype.pipeline import ( schema, register_loader_plugin_path, register_creator_plugin_path, - deregister_loader_plugin_path, - deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) -from openpype.tools.utils import host_tools +from openpype.host import ( + HostBase, + IWorkfileHost, + ILoadHost +) from . import lib from .utils import get_resolve_module +from .workio import ( + open_file, + save_file, + file_extensions, + has_unsaved_changes, + work_root, + current_file +) log = Logger.get_logger(__name__) @@ -32,53 +42,59 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") AVALON_CONTAINERS = ":AVALON_CONTAINERS" -def install(): - """Install resolve-specific functionality of avalon-core. +class ResolveHost(HostBase, IWorkfileHost, ILoadHost): + name = "maya" - This is where you install menus and register families, data - and loaders into resolve. + def __init__(self): + super(ResolveHost, self).__init__() - It is called automatically when installing via `api.install(resolve)`. + def install(self): + """Install resolve-specific functionality of avalon-core. - See the Maya equivalent for inspiration on how to implement this. + This is where you install menus and register families, data + and loaders into resolve. - """ + It is called automatically when installing via `api.install(resolve)`. - log.info("openpype.hosts.resolve installed") + See the Maya equivalent for inspiration on how to implement this. - pyblish.register_host("resolve") - pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering DaVinci Resovle plug-ins..") + """ - register_loader_plugin_path(LOAD_PATH) - register_creator_plugin_path(CREATE_PATH) + log.info("openpype.hosts.resolve installed") - # register callback for switching publishable - pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + pyblish.register_host("resolve") + pyblish.register_plugin_path(PUBLISH_PATH) + print("Registering DaVinci Resolve plug-ins..") - get_resolve_module() + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) + # register callback for switching publishable + pyblish.register_callback("instanceToggled", + on_pyblish_instance_toggled) -def uninstall(): - """Uninstall all that was installed + get_resolve_module() - This is where you undo everything that was done in `install()`. - That means, removing menus, deregistering families and data - and everything. It should be as though `install()` was never run, - because odds are calling this function means the user is interested - in re-installing shortly afterwards. If, for example, he has been - modifying the menu or registered families. + def open_workfile(self, filepath): + return open_file(filepath) - """ - pyblish.deregister_host("resolve") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + def save_workfile(self, filepath=None): + return save_file(filepath) - deregister_loader_plugin_path(LOAD_PATH) - deregister_creator_plugin_path(CREATE_PATH) + def work_root(self, session): + return work_root(session) - # register callback for switching publishable - pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + def get_current_workfile(self): + return current_file() + + def workfile_has_unsaved_changes(self): + return has_unsaved_changes() + + def get_workfile_extensions(self): + return file_extensions() + + def get_containers(self): + return ls() def containerise(timeline_item, @@ -206,15 +222,6 @@ def update_container(timeline_item, data=None): return bool(lib.set_timeline_item_pype_tag(timeline_item, container)) -def launch_workfiles_app(*args): - host_tools.show_workfiles() - - -def publish(parent): - """Shorthand to publish from within host""" - return host_tools.show_publish() - - @contextlib.contextmanager def maintained_selection(): """Maintain selection during context diff --git a/openpype/hosts/resolve/api/utils.py b/openpype/hosts/resolve/api/utils.py index 871b3af38d..851851a3b3 100644 --- a/openpype/hosts/resolve/api/utils.py +++ b/openpype/hosts/resolve/api/utils.py @@ -17,7 +17,7 @@ def get_resolve_module(): # dont run if already loaded if api.bmdvr: log.info(("resolve module is assigned to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) return api.bmdvr try: """ @@ -41,6 +41,10 @@ def get_resolve_module(): ) elif sys.platform.startswith("linux"): expected_path = "/opt/resolve/libs/Fusion/Modules" + else: + raise NotImplementedError( + "Unsupported platform: {}".format(sys.platform) + ) # check if the default path has it... print(("Unable to find module DaVinciResolveScript from " @@ -74,6 +78,6 @@ def get_resolve_module(): api.bmdvr = bmdvr api.bmdvf = bmdvf log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) + f"`openpype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) diff --git a/openpype/hosts/resolve/startup.py b/openpype/hosts/resolve/startup.py index e807a48f5a..5ac3c99524 100644 --- a/openpype/hosts/resolve/startup.py +++ b/openpype/hosts/resolve/startup.py @@ -27,7 +27,8 @@ def ensure_installed_host(): if host: return host - install_host(openpype.hosts.resolve.api) + host = openpype.hosts.resolve.api.ResolveHost() + install_host(host) return registered_host() @@ -37,10 +38,10 @@ def launch_menu(): openpype.hosts.resolve.api.launch_pype_menu() -def open_file(path): +def open_workfile(path): # Avoid the need to "install" the host host = ensure_installed_host() - host.open_file(path) + host.open_workfile(path) def main(): @@ -49,7 +50,7 @@ def main(): if workfile_path and os.path.exists(workfile_path): log.info(f"Opening last workfile: {workfile_path}") - open_file(workfile_path) + open_workfile(workfile_path) else: log.info("No last workfile set to open. Skipping..") diff --git a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py index 1087a7b7a0..4f14927074 100644 --- a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py +++ b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py @@ -8,12 +8,13 @@ log = Logger.get_logger(__name__) def main(env): - import openpype.hosts.resolve.api as bmdvr + from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu # activate resolve from openpype - install_host(bmdvr) + host = ResolveHost() + install_host(host) - bmdvr.launch_pype_menu() + launch_pype_menu() if __name__ == "__main__": From 05b487a435f8add93e638c2bb4433d7c8dc15054 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 4 Oct 2023 20:30:45 +0200 Subject: [PATCH 200/327] Apply suggestions from code review by @iLLiCiTiT Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/resolve/api/pipeline.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 19c7b13371..05f556fa5b 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -43,10 +43,7 @@ AVALON_CONTAINERS = ":AVALON_CONTAINERS" class ResolveHost(HostBase, IWorkfileHost, ILoadHost): - name = "maya" - - def __init__(self): - super(ResolveHost, self).__init__() + name = "resolve" def install(self): """Install resolve-specific functionality of avalon-core. @@ -62,7 +59,7 @@ class ResolveHost(HostBase, IWorkfileHost, ILoadHost): log.info("openpype.hosts.resolve installed") - pyblish.register_host("resolve") + pyblish.register_host(self.name) pyblish.register_plugin_path(PUBLISH_PATH) print("Registering DaVinci Resolve plug-ins..") From 664c27ced2688894ddef217f0fbe423119d68c4d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 15:21:12 +0800 Subject: [PATCH 201/327] make sure it also validates resolution for vray renderer --- .../plugins/publish/validate_resolution.py | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 4c350388e2..7b89e9a3e6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -4,50 +4,76 @@ from openpype.pipeline import ( OptionalPyblishPluginMixin ) from maya import cmds +from openpype.pipeline.publish import RepairAction +from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.lib import reset_scene_resolution -class ValidateResolution(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validate the resolution setting aligned with DB""" +class ValidateSceneResolution(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate the scene resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 families = ["renderlayer"] hosts = ["maya"] label = "Validate Resolution" + actions = [RepairAction] optional = True def process(self, instance): if not self.is_active(instance.data): return - width, height = self.get_db_resolution(instance) - current_width = cmds.getAttr("defaultResolution.width") - current_height = cmds.getAttr("defaultResolution.height") - if current_width != width and current_height != height: - raise PublishValidationError("Resolution Setting " - "not matching resolution " - "set on asset or shot.") - if current_width != width: - raise PublishValidationError("Width in Resolution Setting " - "not matching resolution set " - "on asset or shot.") - - if current_height != height: - raise PublishValidationError("Height in Resolution Setting " - "not matching resolution set " - "on asset or shot.") + width, height, pixelAspect = self.get_db_resolution(instance) + current_renderer = cmds.getAttr( + "defaultRenderGlobals.currentRenderer") + layer = instance.data["renderlayer"] + if current_renderer == "vray": + vray_node = "vraySettings" + if cmds.objExists(vray_node): + control_node = vray_node + current_width = lib.get_attr_in_layer( + "{}.width".format(control_node), layer=layer) + current_height = lib.get_attr_in_layer( + "{}.height".format(control_node), layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "{}.pixelAspect".format(control_node), layer=layer + ) + else: + raise PublishValidationError( + "Can't set VRay resolution because there is no node " + "named: `%s`" % vray_node) + else: + current_width = lib.get_attr_in_layer( + "defaultResolution.width", layer=layer) + current_height = lib.get_attr_in_layer( + "defaultResolution.height", layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer + ) + if current_width != width or current_height != height: + raise PublishValidationError( + "Render resolution is {}x{} does not match asset resolution is {}x{}".format( + current_width, current_height, width, height + )) + if current_pixelAspect != pixelAspect: + raise PublishValidationError( + "Render pixel aspect is {} does not match asset pixel aspect is {}".format( + current_pixelAspect, pixelAspect + )) def get_db_resolution(self, instance): asset_doc = instance.data["assetEntity"] project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: - if "resolutionWidth" in data and "resolutionHeight" in data: + if "resolutionWidth" in data and "resolutionHeight" in data \ + and "pixelAspect" in data: width = data["resolutionWidth"] height = data["resolutionHeight"] - return int(width), int(height) + pixelAspect = data["pixelAspect"] + return int(width), int(height), int(pixelAspect) # Defaults if not found in asset document or project document - return 1920, 1080 + return 1920, 1080, 1 @classmethod def repair(cls, instance): From c81b0af8390a97fb86befae0aa8a310b8864d716 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 15:24:33 +0800 Subject: [PATCH 202/327] hound --- .../hosts/maya/plugins/publish/validate_resolution.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 7b89e9a3e6..856c2811ea 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -52,12 +52,12 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, ) if current_width != width or current_height != height: raise PublishValidationError( - "Render resolution is {}x{} does not match asset resolution is {}x{}".format( + "Render resolution {}x{} does not match asset resolution {}x{}".format( # noqa:E501 current_width, current_height, width, height )) if current_pixelAspect != pixelAspect: - raise PublishValidationError( - "Render pixel aspect is {} does not match asset pixel aspect is {}".format( + raise PublishValidationError( + "Render pixel aspect {} does not match asset pixel aspect {}".format( # noqa:E501 current_pixelAspect, pixelAspect )) @@ -66,7 +66,7 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: if "resolutionWidth" in data and "resolutionHeight" in data \ - and "pixelAspect" in data: + and "pixelAspect" in data: width = data["resolutionWidth"] height = data["resolutionHeight"] pixelAspect = data["pixelAspect"] From d1f5f6eb4a1bfa5f55907eb0bccb9591855914fb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 15:28:30 +0800 Subject: [PATCH 203/327] hound --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 856c2811ea..578c99e006 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -65,8 +65,9 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, asset_doc = instance.data["assetEntity"] project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: - if "resolutionWidth" in data and "resolutionHeight" in data \ - and "pixelAspect" in data: + if "resolutionWidth" in data and ( + "resolutionHeight" in data and "pixelAspect" in data + ): width = data["resolutionWidth"] height = data["resolutionHeight"] pixelAspect = data["pixelAspect"] From d26df62e1502beed52522efe3a4b5a6bb9679ee8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 13:00:52 +0200 Subject: [PATCH 204/327] do not crash if task is not filled --- openpype/plugins/actions/open_file_explorer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py index e4fbd91143..2eb4ee7f8e 100644 --- a/openpype/plugins/actions/open_file_explorer.py +++ b/openpype/plugins/actions/open_file_explorer.py @@ -83,10 +83,6 @@ class OpenTaskPath(LauncherAction): if os.path.exists(valid_workdir): return valid_workdir - # If task was selected, try to find asset path only to asset - if not task_name: - raise AssertionError("Folder does not exist.") - data.pop("task", None) workdir = anatomy.templates_obj["work"]["folder"].format(data) valid_workdir = self._find_first_filled_path(workdir) From 2c68dbcc72a185e69232dc9646dd0c6eebef1f7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 13:01:02 +0200 Subject: [PATCH 205/327] change an error a little bit --- openpype/plugins/actions/open_file_explorer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py index 2eb4ee7f8e..1568c41fbd 100644 --- a/openpype/plugins/actions/open_file_explorer.py +++ b/openpype/plugins/actions/open_file_explorer.py @@ -91,7 +91,7 @@ class OpenTaskPath(LauncherAction): valid_workdir = os.path.normpath(valid_workdir) if os.path.exists(valid_workdir): return valid_workdir - raise AssertionError("Folder does not exist.") + raise AssertionError("Folder does not exist yet.") @staticmethod def open_in_explorer(path): From 9c543d12ddb6057c120565099fab20b5a06bd4b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:44:13 +0200 Subject: [PATCH 206/327] AYON: Small settings fixes (#5699) * add label to nuke 13-0 variant * make 'ExtractReviewIntermediates' settings backwards compatible * add remaining labels for '13-0' variants --- .../nuke/plugins/publish/extract_review_intermediates.py | 4 +++- openpype/settings/ayon_settings.py | 6 ++++-- server_addon/applications/server/applications.json | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index da060e3157..9730e3b61f 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -33,11 +33,13 @@ class ExtractReviewIntermediates(publish.Extractor): """ nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] - current_setting = nuke_publish["ExtractReviewIntermediates"] + current_setting = nuke_publish.get("ExtractReviewIntermediates") if deprecated_setting["enabled"]: # Use deprecated settings if they are still enabled cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] cls.outputs = deprecated_setting["outputs"] + elif current_setting is None: + pass elif current_setting["enabled"]: cls.viewer_lut_raw = current_setting["viewer_lut_raw"] cls.outputs = current_setting["outputs"] diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 68693bb953..d54d71e851 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -748,15 +748,17 @@ def _convert_nuke_project_settings(ayon_settings, output): ) new_review_data_outputs = {} - outputs_settings = None + outputs_settings = [] # Check deprecated ExtractReviewDataMov # settings for backwards compatibility deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] current_review_settings = ( - ayon_publish["ExtractReviewIntermediates"] + ayon_publish.get("ExtractReviewIntermediates") ) if deprecrated_review_settings["enabled"]: outputs_settings = deprecrated_review_settings["outputs"] + elif current_review_settings is None: + pass elif current_review_settings["enabled"]: outputs_settings = current_review_settings["outputs"] diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index 8e5b28623e..e40b8d41f6 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -237,6 +237,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -319,6 +320,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -405,6 +407,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -491,6 +494,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -577,6 +581,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ From d7dcc3862f9e558262b6c1a6a74a24ce24f2a160 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 16:03:28 +0300 Subject: [PATCH 207/327] display changes in menu, add menu button --- openpype/hosts/houdini/api/lib.py | 78 +++++++++++++------ openpype/hosts/houdini/api/pipeline.py | 5 +- .../hosts/houdini/startup/MainMenuCommon.xml | 8 ++ 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ce89ffe606..eea2df7369 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -18,7 +18,7 @@ from openpype.pipeline.context_tools import ( get_current_context_template_data, get_current_project_asset ) - +from openpype.widgets import popup import hou @@ -166,8 +166,6 @@ def validate_fps(): if current_fps != fps: - from openpype.widgets import popup - # Find main window parent = hou.ui.mainQtWindow() if parent is None: @@ -755,31 +753,29 @@ def get_camera_from_container(container): return cameras[0] -def update_houdini_vars_context(): - """Update Houdini vars to match current context. +def get_context_var_changes(): + """get context var changes.""" - This will only do something if the setting is enabled in project settings. - """ + houdini_vars_to_update = {} project_settings = get_current_project_settings() houdini_vars_settings = \ project_settings["houdini"]["general"]["update_houdini_var_context"] if not houdini_vars_settings["enabled"]: - return + return houdini_vars_to_update houdini_vars = houdini_vars_settings["houdini_vars"] # No vars specified - nothing to do if not houdini_vars: - return + return houdini_vars_to_update # Get Template data template_data = get_current_context_template_data() # Set Houdini Vars for item in houdini_vars: - # For consistency reasons we always force all vars to be uppercase item["var"] = item["var"].upper() @@ -789,21 +785,13 @@ def update_houdini_vars_context(): template_data ) - if item["is_directory"]: - item_value = item_value.replace("\\", "/") - try: - os.makedirs(item_value) - except OSError as e: - if e.errno != errno.EEXIST: - print( - " - Failed to create ${} dir. Maybe due to " - "insufficient permissions.".format(item["var"]) - ) - if item["var"] == "JOB" and item_value == "": # sync $JOB to $HIP if $JOB is empty item_value = os.environ["HIP"] + if item["is_directory"]: + item_value = item_value.replace("\\", "/") + current_value = hou.hscript("echo -n `${}`".format(item["var"]))[0] # sync both environment variables. @@ -812,7 +800,49 @@ def update_houdini_vars_context(): os.environ[item["var"]] = current_value if current_value != item_value: - hou.hscript("set {}={}".format(item["var"], item_value)) - os.environ[item["var"]] = item_value + houdini_vars_to_update.update({item["var"]: (current_value, item_value, item["is_directory"])}) - print(" - Updated ${} to {}".format(item["var"], item_value)) + return houdini_vars_to_update + + +def update_houdini_vars_context(): + """Update asset context variables""" + + for var, (old, new, is_directory) in get_context_var_changes().items(): + if is_directory: + try: + os.makedirs(new) + except OSError as e: + if e.errno != errno.EEXIST: + print( + " - Failed to create ${} dir. Maybe due to " + "insufficient permissions.".format(var) + ) + + hou.hscript("set {}={}".format(var, new)) + os.environ[var] = new + print(" - Updated ${} to {}".format(var, new)) + + +def update_houdini_vars_context_dialog(): + """Show pop-up to update asset context variables""" + update_vars = get_context_var_changes() + if not update_vars: + # Nothing to change + return + + message = "\n".join( + "${}: {} -> {}".format(var, old or "None", new) + for var, (old, new, is_directory) in update_vars.items() + ) + parent = hou.ui.mainQtWindow() + dialog = popup.PopupUpdateKeys(parent=parent) + dialog.setModal(True) + dialog.setWindowTitle("Houdini scene has outdated asset variables") + dialog.setMessage(message) + dialog.setButtonText("Fix") + + # on_show is the Fix button clicked callback + dialog.on_clicked_state.connect(lambda: update_houdini_vars_context()) + + dialog.show() diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index f753d518f0..f8db45c56b 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -301,7 +301,7 @@ def on_save(): log.info("Running callback on save..") # update houdini vars - lib.update_houdini_vars_context() + lib.update_houdini_vars_context_dialog() nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): @@ -339,7 +339,7 @@ def on_open(): log.info("Running callback on open..") # update houdini vars - lib.update_houdini_vars_context() + lib.update_houdini_vars_context_dialog() # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset @@ -405,6 +405,7 @@ def _set_context_settings(): """ lib.reset_framerange() + lib.update_houdini_vars_context() def on_pyblish_instance_toggled(instance, new_value, old_value): diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 5818a117eb..b2e32a70f9 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -86,6 +86,14 @@ openpype.hosts.houdini.api.lib.reset_framerange() ]]> + + + + + From 809b6df22178fda6c3b496cd49edc6799f9c3081 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 16:05:47 +0300 Subject: [PATCH 208/327] resolve hound --- openpype/hosts/houdini/api/lib.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index eea2df7369..68ba4589d9 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -800,7 +800,13 @@ def get_context_var_changes(): os.environ[item["var"]] = current_value if current_value != item_value: - houdini_vars_to_update.update({item["var"]: (current_value, item_value, item["is_directory"])}) + houdini_vars_to_update.update( + { + item["var"]: ( + current_value, item_value, item["is_directory"] + ) + } + ) return houdini_vars_to_update From 35194b567f7599b9480b8ba3e048229a0503faa0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 16:06:35 +0300 Subject: [PATCH 209/327] resolve hound 2 --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 68ba4589d9..fa94ddfeb4 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -814,7 +814,7 @@ def get_context_var_changes(): def update_houdini_vars_context(): """Update asset context variables""" - for var, (old, new, is_directory) in get_context_var_changes().items(): + for var, (_old, new, is_directory) in get_context_var_changes().items(): if is_directory: try: os.makedirs(new) From 0af1b5846c31602944bb78d396d6e8fdd23b23bd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 16:07:19 +0300 Subject: [PATCH 210/327] resolve hound 3 --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index fa94ddfeb4..e4040852b9 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -839,7 +839,7 @@ def update_houdini_vars_context_dialog(): message = "\n".join( "${}: {} -> {}".format(var, old or "None", new) - for var, (old, new, is_directory) in update_vars.items() + for var, (old, new, _is_directory) in update_vars.items() ) parent = hou.ui.mainQtWindow() dialog = popup.PopupUpdateKeys(parent=parent) From 3daa0749d1a40eb0c22214fb69cc5ef76965b65d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 5 Oct 2023 15:08:31 +0200 Subject: [PATCH 211/327] AYON Launcher tool: Fix skip last workfile boolean (#5700) * reverse the boolean to skip last workfile * remove 'start_last_workfile' key to keep logic based on settings * change 'skip_last_workfile' for all variants of DCC * fix context menu on ungrouped items * better sort of action items --- openpype/tools/ayon_launcher/abstract.py | 4 +- openpype/tools/ayon_launcher/control.py | 4 +- .../tools/ayon_launcher/models/actions.py | 10 +++-- .../tools/ayon_launcher/ui/actions_widget.py | 37 +++++++++++++++++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py index 00502fe930..f2ef681c62 100644 --- a/openpype/tools/ayon_launcher/abstract.py +++ b/openpype/tools/ayon_launcher/abstract.py @@ -272,7 +272,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): @abstractmethod def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): """This is application action related to force not open last workfile. @@ -280,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. + action_id (Iterable[str]): Action identifiers. enabled (bool): New value of force not open workfile. """ diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py index 09e07893c3..a6e528b104 100644 --- a/openpype/tools/ayon_launcher/control.py +++ b/openpype/tools/ayon_launcher/control.py @@ -121,10 +121,10 @@ class BaseLauncherController( project_name, folder_id, task_id) def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_id, enabled + project_name, folder_id, task_id, action_ids, enabled ) def trigger_action(self, project_name, folder_id, task_id, identifier): diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py index 24fea44db2..93ec115734 100644 --- a/openpype/tools/ayon_launcher/models/actions.py +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -326,13 +326,14 @@ class ActionsModel: return output def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_id, enabled + self, project_name, folder_id, task_id, action_ids, enabled ): no_workfile_reg_data = self._get_no_last_workfile_reg_data() project_data = no_workfile_reg_data.setdefault(project_name, {}) folder_data = project_data.setdefault(folder_id, {}) task_data = folder_data.setdefault(task_id, {}) - task_data[action_id] = enabled + for action_id in action_ids: + task_data[action_id] = enabled self._launcher_tool_reg.set_item( self._not_open_workfile_reg_key, no_workfile_reg_data ) @@ -359,7 +360,10 @@ class ActionsModel: project_name, folder_id, task_id ) force_not_open_workfile = per_action.get(identifier, False) - action.data["start_last_workfile"] = force_not_open_workfile + if force_not_open_workfile: + action.data["start_last_workfile"] = False + else: + action.data.pop("start_last_workfile", None) action.process(session) except Exception as exc: self.log.warning("Action trigger failed.", exc_info=True) diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py index d04f8f8d24..0630d1d5b5 100644 --- a/openpype/tools/ayon_launcher/ui/actions_widget.py +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -19,6 +19,21 @@ ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +def _variant_label_sort_getter(action_item): + """Get variant label value for sorting. + + Make sure the output value is a string. + + Args: + action_item (ActionItem): Action item. + + Returns: + str: Variant label or empty string. + """ + + return action_item.variant_label or "" + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -51,6 +66,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._controller = controller self._items_by_id = {} + self._action_items_by_id = {} self._groups_by_id = {} self._selected_project_name = None @@ -72,8 +88,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_action_item_by_id(self, action_id): + return self._action_items_by_id.get(action_id) + def _clear_items(self): self._items_by_id = {} + self._action_items_by_id = {} self._groups_by_id = {} root = self.invisibleRootItem() root.removeRows(0, root.rowCount()) @@ -101,12 +121,14 @@ class ActionsQtModel(QtGui.QStandardItemModel): groups_by_id = {} for action_items in items_by_label.values(): + action_items.sort(key=_variant_label_sort_getter, reverse=True) first_item = next(iter(action_items)) all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items new_items = [] items_by_id = {} + action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info icon = get_qt_icon(action_item.icon) @@ -132,6 +154,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): action_item.force_not_open_workfile, FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item + action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -139,10 +162,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys()) for identifier in to_remove: item = self._items_by_id.pop(identifier) + self._action_items_by_id.pop(identifier) root_item.removeRow(item.row()) self._groups_by_id = groups_by_id self._items_by_id = items_by_id + self._action_items_by_id = action_items_by_id self.refreshed.emit() def _on_controller_refresh_finished(self): @@ -387,9 +412,15 @@ class ActionsWidget(QtWidgets.QWidget): checkbox.setChecked(True) action_id = index.data(ACTION_ID_ROLE) + is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + action_items = self._model.get_group_items(action_id) + else: + action_items = [self._model.get_action_item_by_id(action_id)] + action_ids = {action_item.identifier for action_item in action_items} checkbox.stateChanged.connect( lambda: self._on_checkbox_changed( - action_id, checkbox.isChecked() + action_ids, checkbox.isChecked() ) ) action = QtWidgets.QWidgetAction(menu) @@ -402,7 +433,7 @@ class ActionsWidget(QtWidgets.QWidget): menu.exec_(global_point) self._context_menu = None - def _on_checkbox_changed(self, action_id, is_checked): + def _on_checkbox_changed(self, action_ids, is_checked): if self._context_menu is not None: self._context_menu.close() @@ -410,7 +441,7 @@ class ActionsWidget(QtWidgets.QWidget): folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_id, is_checked) + project_name, folder_id, task_id, action_ids, is_checked) self._model.refresh() def _on_clicked(self, index): From 31d77932ede38fbc5c5eda29df5fcf920210a0e7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 16:15:26 +0300 Subject: [PATCH 212/327] print message to user if nothing to change --- openpype/hosts/houdini/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index e4040852b9..1f71481cc6 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -835,6 +835,7 @@ def update_houdini_vars_context_dialog(): update_vars = get_context_var_changes() if not update_vars: # Nothing to change + print(" - Nothing to change, Houdini Vars are up to date.") return message = "\n".join( From e255c20c440211d3578fc7bcc7b350b6756dd859 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 5 Oct 2023 15:37:45 +0200 Subject: [PATCH 213/327] Remove checks for env var (#5696) Env var will be filled in `env_var` fixture, here it is too early to check --- openpype/pype_commands.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 7adebbbc97..071ecfffd2 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -271,12 +271,6 @@ class PypeCommands: if mongo_url: args.extend(["--mongo_url", mongo_url]) - else: - msg = ( - "Either provide uri to MongoDB through environment variable" - " OPENPYPE_MONGO or the command flag --mongo_url" - ) - assert not os.environ.get("OPENPYPE_MONGO"), msg print("run_tests args: {}".format(args)) import pytest From 52c65c9b6cd194f115f64df850e45764bdf3653a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 5 Oct 2023 16:03:56 +0200 Subject: [PATCH 214/327] Fusion: implement toggle to use Deadline plugin FusionCmd (#5678) * OP-6971 - changed DL plugin to FusionCmd Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant. * OP-6971 - added dropdown to Project Settings * OP-6971 - updated settings for Ayon * OP-6971 - added default * OP-6971 - bumped up version * Update openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json Co-authored-by: Roy Nieterau --------- Co-authored-by: Roy Nieterau --- .../plugins/publish/submit_fusion_deadline.py | 4 +++- .../defaults/project_settings/deadline.json | 3 ++- .../schema_project_deadline.json | 9 ++++++++ .../server/settings/publish_plugins.py | 21 +++++++++++++++++++ server_addon/deadline/server/version.py | 2 +- 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index 70aa12956d..c91dd4bd69 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -34,6 +34,8 @@ class FusionSubmitDeadline( targets = ["local"] # presets + plugin = None + priority = 50 chunk_size = 1 concurrent_tasks = 1 @@ -173,7 +175,7 @@ class FusionSubmitDeadline( "SecondaryPool": instance.data.get("secondaryPool"), "Group": self.group, - "Plugin": "Fusion", + "Plugin": self.plugin, "Frames": "{start}-{end}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 9e88f3b6f2..2c5e0dc65d 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -52,7 +52,8 @@ "priority": 50, "chunk_size": 10, "concurrent_tasks": 1, - "group": "" + "group": "", + "plugin": "Fusion" }, "NukeSubmitDeadline": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 596bc30f91..64db852c89 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -289,6 +289,15 @@ "type": "text", "key": "group", "label": "Group Name" + }, + { + "type": "enum", + "key": "plugin", + "label": "Deadline Plugin", + "enum_items": [ + {"Fusion": "Fusion"}, + {"FusionCmd": "FusionCmd"} + ] } ] }, diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 32a5d0e353..8d48695a9c 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -124,6 +124,24 @@ class LimitGroupsSubmodel(BaseSettingsModel): ) +def fusion_deadline_plugin_enum(): + """Return a list of value/label dicts for the enumerator. + + Returning a list of dicts is used to allow for a custom label to be + displayed in the UI. + """ + return [ + { + "value": "Fusion", + "label": "Fusion" + }, + { + "value": "FusionCmd", + "label": "FusionCmd" + } + ] + + class FusionSubmitDeadlineModel(BaseSettingsModel): enabled: bool = Field(True, title="Enabled") optional: bool = Field(False, title="Optional") @@ -132,6 +150,9 @@ class FusionSubmitDeadlineModel(BaseSettingsModel): chunk_size: int = Field(10, title="Frame per Task") concurrent_tasks: int = Field(1, title="Number of concurrent tasks") group: str = Field("", title="Group Name") + plugin: str = Field("Fusion", + enum_resolver=fusion_deadline_plugin_enum, + title="Deadline Plugin") class NukeSubmitDeadlineModel(BaseSettingsModel): diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From f3e02c1e95ac0e085630c370431301de0b74ccd4 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 5 Oct 2023 15:05:18 +0100 Subject: [PATCH 215/327] Add MayaPy application --- ...oundry_apps.py => pre_new_console_apps.py} | 8 ++- openpype/hosts/maya/api/pipeline.py | 3 +- openpype/hosts/maya/hooks/pre_copy_mel.py | 2 +- .../system_settings/applications.json | 59 +++++++++++++++++++ .../host_settings/schema_mayapy.json | 39 ++++++++++++ .../system_schema/schema_applications.json | 4 ++ 6 files changed, 110 insertions(+), 5 deletions(-) rename openpype/hooks/{pre_foundry_apps.py => pre_new_console_apps.py} (82%) create mode 100644 openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_new_console_apps.py similarity index 82% rename from openpype/hooks/pre_foundry_apps.py rename to openpype/hooks/pre_new_console_apps.py index 7536df4c16..9727b4fb78 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_new_console_apps.py @@ -2,7 +2,7 @@ import subprocess from openpype.lib.applications import PreLaunchHook, LaunchTypes -class LaunchFoundryAppsWindows(PreLaunchHook): +class LaunchNewConsoleApps(PreLaunchHook): """Foundry applications have specific way how to launch them. Nuke is executed "like" python process so it is required to pass @@ -13,13 +13,15 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"} + app_groups = { + "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy" + } platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE - # - on Windows nuke will create new window using its console + # - on Windows some apps will create new window using its console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 38d7ae08c1..6b791c9665 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -95,6 +95,8 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): self.log.info("Installing callbacks ... ") register_event_callback("init", on_init) + _set_project() + if lib.IS_HEADLESS: self.log.info(( "Running in headless mode, skipping Maya save/open/new" @@ -103,7 +105,6 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): return - _set_project() self._register_callbacks() menu.install(project_settings) diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index 0fb5af149a..6cd2c69e20 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = {"maya"} + app_groups = {"maya", "mayapy"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f2fc7d933a..b100704ffe 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -114,6 +114,65 @@ } } }, + "mayapy": { + "enabled": true, + "label": "MayaPy", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": { + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C" + }, + "variants": { + "2024": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [ + "-I" + ], + "darwin": [], + "linux": [ + "-I" + ] + }, + "environment": {} + }, + "2023": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [ + "-I" + ], + "darwin": [], + "linux": [ + "-I" + ] + }, + "environment": {} + } + } + }, "3dsmax": { "enabled": true, "label": "3ds max", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json new file mode 100644 index 0000000000..bbdc7e13b0 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "mayapy", + "label": "Autodesk MayaPy", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index abea37a9ab..7965c344ae 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -9,6 +9,10 @@ "type": "schema", "name": "schema_maya" }, + { + "type": "schema", + "name": "schema_mayapy" + }, { "type": "schema", "name": "schema_3dsmax" From c6b370be9aec3b4f6d262e34f911e6dcad0913fd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 17:23:53 +0300 Subject: [PATCH 216/327] BigRoy's comments --- openpype/hosts/houdini/api/lib.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 1f71481cc6..3b38a6669f 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -777,7 +777,7 @@ def get_context_var_changes(): # Set Houdini Vars for item in houdini_vars: # For consistency reasons we always force all vars to be uppercase - item["var"] = item["var"].upper() + var = item["var"].upper() # get and resolve template in value item_value = StringTemplate.format_template( @@ -785,27 +785,18 @@ def get_context_var_changes(): template_data ) - if item["var"] == "JOB" and item_value == "": + if var == "JOB" and item_value == "": # sync $JOB to $HIP if $JOB is empty item_value = os.environ["HIP"] if item["is_directory"]: item_value = item_value.replace("\\", "/") - current_value = hou.hscript("echo -n `${}`".format(item["var"]))[0] - - # sync both environment variables. - # because houdini doesn't do that by default - # on opening new files - os.environ[item["var"]] = current_value + current_value = hou.hscript("echo -n `${}`".format(var))[0] if current_value != item_value: - houdini_vars_to_update.update( - { - item["var"]: ( - current_value, item_value, item["is_directory"] - ) - } + houdini_vars_to_update[var] = ( + current_value, item_value, item["is_directory"] ) return houdini_vars_to_update @@ -821,13 +812,13 @@ def update_houdini_vars_context(): except OSError as e: if e.errno != errno.EEXIST: print( - " - Failed to create ${} dir. Maybe due to " + "Failed to create ${} dir. Maybe due to " "insufficient permissions.".format(var) ) hou.hscript("set {}={}".format(var, new)) os.environ[var] = new - print(" - Updated ${} to {}".format(var, new)) + print("Updated ${} to {}".format(var, new)) def update_houdini_vars_context_dialog(): @@ -835,7 +826,7 @@ def update_houdini_vars_context_dialog(): update_vars = get_context_var_changes() if not update_vars: # Nothing to change - print(" - Nothing to change, Houdini Vars are up to date.") + print("Nothing to change, Houdini Vars are up to date.") return message = "\n".join( @@ -850,6 +841,6 @@ def update_houdini_vars_context_dialog(): dialog.setButtonText("Fix") # on_show is the Fix button clicked callback - dialog.on_clicked_state.connect(lambda: update_houdini_vars_context()) + dialog.on_clicked_state.connect(update_houdini_vars_context) dialog.show() From 8f0b1827595ef77fa2adcbe2a661c53f99d87513 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 17:26:08 +0300 Subject: [PATCH 217/327] update printed message --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 3b38a6669f..44752a3369 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -826,7 +826,7 @@ def update_houdini_vars_context_dialog(): update_vars = get_context_var_changes() if not update_vars: # Nothing to change - print("Nothing to change, Houdini Vars are up to date.") + print("Nothing to change, Houdini vars are already up to date.") return message = "\n".join( From 12f41289018c46ab09eb5336a3dcdea93057183d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 5 Oct 2023 16:46:20 +0200 Subject: [PATCH 218/327] Fusion: added missing env vars to Deadline submission (#5659) * OP-6930 - added missing env vars to Fusion Deadline submission Without this injection of environment variables won't start. * OP-6930 - removed unnecessary env var * OP-6930 - removed unnecessary env var --- .../plugins/publish/submit_fusion_deadline.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index c91dd4bd69..0b97582d2a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -6,6 +6,7 @@ import requests import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin @@ -218,16 +219,29 @@ class FusionSubmitDeadline( # Include critical variables with submission keys = [ - # TODO: This won't work if the slaves don't have access to - # these paths, such as if slaves are running Linux and the - # submitter is on Windows. - "PYTHONPATH", - "OFX_PLUGIN_PATH", - "FUSION9_MasterPrefs" + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_LOG_NO_COLORS", + "IS_TEST" ] environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) + # to recognize render jobs + if AYON_SERVER_ENABLED: + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + render_job_label = "AYON_RENDER_JOB" + else: + render_job_label = "OPENPYPE_RENDER_JOB" + + environment[render_job_label] = "1" + payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, From 5b04af7ea138641bb5813ea7894044d03d8285c9 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 19:30:44 +0300 Subject: [PATCH 219/327] remove leading and trailing whitespaces from vars --- openpype/hosts/houdini/api/lib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 44752a3369..75986c71f5 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -777,7 +777,8 @@ def get_context_var_changes(): # Set Houdini Vars for item in houdini_vars: # For consistency reasons we always force all vars to be uppercase - var = item["var"].upper() + # Also remove any leading, and trailing whitespaces. + var = item["var"].strip().upper() # get and resolve template in value item_value = StringTemplate.format_template( From 2ea8d6530fac1818afb98e04d90484f2456614cc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Oct 2023 10:44:39 +0200 Subject: [PATCH 220/327] AYON Launcher tool: Fix refresh btn (#5685) * rename 'refresh' to 'set_context' in 'TasksModel' * implemented 'refresh' for folders and tasks widgets * propagate refresh to all widgets * don't use 'clear' of 'QStandardItemModel' * change lifetime of folders cache to a minute * added 'refresh_actions' method to launcher to skip clear cache of folders * shorten line * sorting is not case sensitive --- openpype/tools/ayon_launcher/abstract.py | 10 +++++ openpype/tools/ayon_launcher/control.py | 12 ++++++ .../tools/ayon_launcher/ui/actions_widget.py | 14 ++----- .../tools/ayon_launcher/ui/hierarchy_page.py | 4 ++ .../tools/ayon_launcher/ui/projects_widget.py | 13 +++++++ openpype/tools/ayon_launcher/ui/window.py | 39 +++++++++++++------ openpype/tools/ayon_utils/models/hierarchy.py | 13 +++++-- .../ayon_utils/widgets/folders_widget.py | 30 ++++++++++---- .../tools/ayon_utils/widgets/tasks_widget.py | 31 ++++++++++----- 9 files changed, 124 insertions(+), 42 deletions(-) diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py index f2ef681c62..95fe2b2c8d 100644 --- a/openpype/tools/ayon_launcher/abstract.py +++ b/openpype/tools/ayon_launcher/abstract.py @@ -295,3 +295,13 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """ pass + + @abstractmethod + def refresh_actions(self): + """Refresh actions and all related data. + + Triggers 'controller.refresh.actions.started' event at the beginning + and 'controller.refresh.actions.finished' at the end. + """ + + pass diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py index a6e528b104..36c0536422 100644 --- a/openpype/tools/ayon_launcher/control.py +++ b/openpype/tools/ayon_launcher/control.py @@ -145,5 +145,17 @@ class BaseLauncherController( self._emit_event("controller.refresh.finished") + def refresh_actions(self): + self._emit_event("controller.refresh.actions.started") + + # Refresh project settings (used for actions discovery) + self._project_settings = {} + # Refresh projects - they define applications + self._projects_model.reset() + # Refresh actions + self._actions_model.refresh() + + self._emit_event("controller.refresh.actions.finished") + def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py index 0630d1d5b5..2a1a06695d 100644 --- a/openpype/tools/ayon_launcher/ui/actions_widget.py +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -46,10 +46,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): def __init__(self, controller): super(ActionsQtModel, self).__init__() - controller.register_event_callback( - "controller.refresh.finished", - self._on_controller_refresh_finished, - ) controller.register_event_callback( "selection.project.changed", self._on_selection_project_changed, @@ -170,13 +166,6 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._action_items_by_id = action_items_by_id self.refreshed.emit() - def _on_controller_refresh_finished(self): - context = self._controller.get_selected_context() - self._selected_project_name = context["project_name"] - self._selected_folder_id = context["folder_id"] - self._selected_task_id = context["task_id"] - self.refresh() - def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -361,6 +350,9 @@ class ActionsWidget(QtWidgets.QWidget): self._set_row_height(1) + def refresh(self): + self._model.refresh() + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py index 5047cdc692..8c546b38ac 100644 --- a/openpype/tools/ayon_launcher/ui/hierarchy_page.py +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -92,6 +92,10 @@ class HierarchyPage(QtWidgets.QWidget): if visible and project_name: self._projects_combobox.set_selection(project_name) + def refresh(self): + self._folders_widget.refresh() + self._tasks_widget.refresh() + def _on_back_clicked(self): self._controller.set_selected_project(None) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index baa399d0ed..7dbaec5147 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -73,6 +73,9 @@ class ProjectIconView(QtWidgets.QListView): class ProjectsWidget(QtWidgets.QWidget): """Projects Page""" + + refreshed = QtCore.Signal() + def __init__(self, controller, parent=None): super(ProjectsWidget, self).__init__(parent=parent) @@ -104,6 +107,7 @@ class ProjectsWidget(QtWidgets.QWidget): main_layout.addWidget(projects_view, 1) projects_view.clicked.connect(self._on_view_clicked) + projects_model.refreshed.connect(self.refreshed) projects_filter_text.textChanged.connect( self._on_project_filter_change) refresh_btn.clicked.connect(self._on_refresh_clicked) @@ -119,6 +123,15 @@ class ProjectsWidget(QtWidgets.QWidget): self._projects_model = projects_model self._projects_proxy_model = projects_proxy_model + def has_content(self): + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + """ + + return self._projects_model.has_content() + def _on_view_clicked(self, index): if index.isValid(): project_name = index.data(QtCore.Qt.DisplayRole) diff --git a/openpype/tools/ayon_launcher/ui/window.py b/openpype/tools/ayon_launcher/ui/window.py index 139da42a2e..ffc74a2fdc 100644 --- a/openpype/tools/ayon_launcher/ui/window.py +++ b/openpype/tools/ayon_launcher/ui/window.py @@ -99,8 +99,8 @@ class LauncherWindow(QtWidgets.QWidget): message_timer.setInterval(self.message_interval) message_timer.setSingleShot(True) - refresh_timer = QtCore.QTimer() - refresh_timer.setInterval(self.refresh_interval) + actions_refresh_timer = QtCore.QTimer() + actions_refresh_timer.setInterval(self.refresh_interval) page_slide_anim = QtCore.QVariantAnimation(self) page_slide_anim.setDuration(self.page_side_anim_interval) @@ -108,8 +108,10 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) + projects_page.refreshed.connect(self._on_projects_refresh) message_timer.timeout.connect(self._on_message_timeout) - refresh_timer.timeout.connect(self._on_refresh_timeout) + actions_refresh_timer.timeout.connect( + self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) @@ -132,6 +134,7 @@ class LauncherWindow(QtWidgets.QWidget): self._is_on_projects_page = True self._window_is_active = False self._refresh_on_activate = False + self._selected_project_name = None self._pages_widget = pages_widget self._pages_layout = pages_layout @@ -143,7 +146,7 @@ class LauncherWindow(QtWidgets.QWidget): # self._action_history = action_history self._message_timer = message_timer - self._refresh_timer = refresh_timer + self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim hierarchy_page.setVisible(not self._is_on_projects_page) @@ -152,14 +155,14 @@ class LauncherWindow(QtWidgets.QWidget): def showEvent(self, event): super(LauncherWindow, self).showEvent(event) self._window_is_active = True - if not self._refresh_timer.isActive(): - self._refresh_timer.start() + if not self._actions_refresh_timer.isActive(): + self._actions_refresh_timer.start() self._controller.refresh() def closeEvent(self, event): super(LauncherWindow, self).closeEvent(event) self._window_is_active = False - self._refresh_timer.stop() + self._actions_refresh_timer.stop() def changeEvent(self, event): if event.type() in ( @@ -170,15 +173,15 @@ class LauncherWindow(QtWidgets.QWidget): self._window_is_active = is_active if is_active and self._refresh_on_activate: self._refresh_on_activate = False - self._on_refresh_timeout() - self._refresh_timer.start() + self._on_actions_refresh_timeout() + self._actions_refresh_timer.start() super(LauncherWindow, self).changeEvent(event) - def _on_refresh_timeout(self): + def _on_actions_refresh_timeout(self): # Stop timer if widget is not visible if self._window_is_active: - self._controller.refresh() + self._controller.refresh_actions() else: self._refresh_on_activate = True @@ -191,12 +194,26 @@ class LauncherWindow(QtWidgets.QWidget): def _on_project_selection_change(self, event): project_name = event["project_name"] + self._selected_project_name = project_name if not project_name: self._go_to_projects_page() elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_projects_refresh(self): + # There is nothing to do, we're on projects page + if self._is_on_projects_page: + return + + # No projects were found -> go back to projects page + if not self._projects_page.has_content(): + self._go_to_projects_page() + return + + self._hierarchy_page.refresh() + self._actions_widget.refresh() + def _on_action_trigger_started(self, event): self._echo("Running action: {}".format(event["full_label"])) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 8e01c557c5..93f4c48d98 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -199,13 +199,18 @@ class HierarchyModel(object): Hierarchy items are folders and tasks. Folders can have as parent another folder or project. Tasks can have as parent only folder. """ + lifetime = 60 # A minute def __init__(self, controller): - self._folders_items = NestedCacheItem(levels=1, default_factory=dict) - self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict) + self._folders_items = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) + self._folders_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) - self._task_items = NestedCacheItem(levels=2, default_factory=dict) - self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict) + self._task_items = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._tasks_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) self._folders_refreshing = set() self._tasks_refreshing = set() diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 3fab64f657..4f44881081 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -56,11 +56,21 @@ class FoldersModel(QtGui.QStandardItemModel): return self._has_content - def clear(self): + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self.set_project_name(self._last_project_name) + + def _clear_items(self): self._items_by_id = {} self._parent_id_by_id = {} self._has_content = False - super(FoldersModel, self).clear() + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) def get_index_by_id(self, item_id): """Get index by folder id. @@ -90,7 +100,7 @@ class FoldersModel(QtGui.QStandardItemModel): self._is_refreshing = True if self._last_project_name != project_name: - self.clear() + self._clear_items() self._last_project_name = project_name thread = self._refresh_threads.get(project_name) @@ -135,7 +145,7 @@ class FoldersModel(QtGui.QStandardItemModel): def _fill_items(self, folder_items_by_id): if not folder_items_by_id: if folder_items_by_id is not None: - self.clear() + self._clear_items() self._is_refreshing = False self.refreshed.emit() return @@ -247,6 +257,7 @@ class FoldersWidget(QtWidgets.QWidget): folders_model = FoldersModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) folders_view.setModel(folders_proxy_model) @@ -293,6 +304,14 @@ class FoldersWidget(QtWidgets.QWidget): self._folders_proxy_model.setFilterFixedString(name) + def refresh(self): + """Refresh folders model. + + Force to update folders model from controller. + """ + + self._folders_model.refresh() + def _on_project_selection_change(self, event): project_name = event["project_name"] self._set_project_name(project_name) @@ -300,9 +319,6 @@ class FoldersWidget(QtWidgets.QWidget): def _set_project_name(self, project_name): self._folders_model.set_project_name(project_name) - def _clear(self): - self._folders_model.clear() - def _on_folders_refresh_finished(self, event): if event["sender"] != SENDER_NAME: self._set_project_name(event["project_name"]) diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index 66ebd0b777..0af506863a 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -44,14 +44,20 @@ class TasksModel(QtGui.QStandardItemModel): # Initial state self._add_invalid_selection_item() - def clear(self): + def _clear_items(self): self._items_by_name = {} self._has_content = False self._remove_invalid_items() - super(TasksModel, self).clear() + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) - def refresh(self, project_name, folder_id): - """Refresh tasks for folder. + def refresh(self): + """Refresh tasks for last project and folder.""" + + self._refresh(self._last_project_name, self._last_folder_id) + + def set_context(self, project_name, folder_id): + """Set context for which should be tasks showed. Args: project_name (Union[str]): Name of project. @@ -121,7 +127,7 @@ class TasksModel(QtGui.QStandardItemModel): return self._empty_tasks_item def _add_invalid_item(self, item): - self.clear() + self._clear_items() root_item = self.invisibleRootItem() root_item.appendRow(item) @@ -299,6 +305,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_model = TasksModel(controller) tasks_proxy_model = QtCore.QSortFilterProxyModel() tasks_proxy_model.setSourceModel(tasks_model) + tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) tasks_view.setModel(tasks_proxy_model) @@ -334,8 +341,14 @@ class TasksWidget(QtWidgets.QWidget): self._handle_expected_selection = handle_expected_selection self._expected_selection_data = None - def _clear(self): - self._tasks_model.clear() + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self._tasks_model.refresh() def _on_tasks_refresh_finished(self, event): """Tasks were refreshed in controller. @@ -353,13 +366,13 @@ class TasksWidget(QtWidgets.QWidget): or event["folder_id"] != self._selected_folder_id ): return - self._tasks_model.refresh( + self._tasks_model.set_context( event["project_name"], self._selected_folder_id ) def _folder_selection_changed(self, event): self._selected_folder_id = event["folder_id"] - self._tasks_model.refresh( + self._tasks_model.set_context( event["project_name"], self._selected_folder_id ) From 7c5d149f56c7aba57c3325fadd43075ec732580d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 12:11:51 +0300 Subject: [PATCH 221/327] use different popup --- openpype/hosts/houdini/api/lib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75986c71f5..3db18ca69a 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -831,17 +831,19 @@ def update_houdini_vars_context_dialog(): return message = "\n".join( - "${}: {} -> {}".format(var, old or "None", new) + "${}: {} -> {}".format(var, old or "None", new or "None") for var, (old, new, _is_directory) in update_vars.items() ) + + # TODO: Use better UI! parent = hou.ui.mainQtWindow() - dialog = popup.PopupUpdateKeys(parent=parent) + dialog = popup.Popup(parent=parent) dialog.setModal(True) dialog.setWindowTitle("Houdini scene has outdated asset variables") dialog.setMessage(message) dialog.setButtonText("Fix") # on_show is the Fix button clicked callback - dialog.on_clicked_state.connect(update_houdini_vars_context) + dialog.on_clicked.connect(update_houdini_vars_context) dialog.show() From 908e980a404bf33dd7657414658cbd801ceb86d0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 13:21:25 +0200 Subject: [PATCH 222/327] updating importing to media pool to newer api --- openpype/hosts/resolve/api/lib.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 735d2057f8..fb4b08cc1e 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -196,7 +196,6 @@ def create_media_pool_item(fpath: str, object: resolve.MediaPoolItem """ # get all variables - media_storage = get_media_storage() media_pool = get_current_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() @@ -205,23 +204,10 @@ def create_media_pool_item(fpath: str, if existing_mpi: return existing_mpi + # add all data in folder to media pool + media_pool_items = media_pool.ImportMedia(fpath) - dirname, file = os.path.split(fpath) - _name, ext = os.path.splitext(file) - - # add all data in folder to mediapool - media_pool_items = media_storage.AddItemListToMediaPool( - os.path.normpath(dirname)) - - if not media_pool_items: - return False - - # if any are added then look into them for the right extension - media_pool_item = [mpi for mpi in media_pool_items - if ext in mpi.GetClipProperty("File Path")] - - # return only first found - return media_pool_item.pop() + return media_pool_items.pop() if media_pool_items else False def get_media_pool_item(fpath, root: object = None) -> object: From 69c8d1985b58b2e5151cb00ec102f81c26d9d93b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:46:49 +0800 Subject: [PATCH 223/327] tweaks on the validation report & repair action --- .../plugins/publish/validate_resolution.py | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 578c99e006..b1752aa4bd 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -11,7 +11,7 @@ from openpype.hosts.maya.api.lib import reset_scene_resolution class ValidateSceneResolution(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): - """Validate the scene resolution setting aligned with DB""" + """Validate the render resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 families = ["renderlayer"] @@ -23,25 +23,34 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, def process(self, instance): if not self.is_active(instance.data): return + invalid = self.get_invalid_resolution(instance) + if invalid: + raise PublishValidationError("issues occurred", description=( + "Wrong render resolution setting. Please use repair button to fix it.\n" + "If current renderer is vray, make sure vraySettings node has been created" + )) + + def get_invalid_resolution(self, instance): width, height, pixelAspect = self.get_db_resolution(instance) current_renderer = cmds.getAttr( "defaultRenderGlobals.currentRenderer") layer = instance.data["renderlayer"] + invalids = [] if current_renderer == "vray": vray_node = "vraySettings" if cmds.objExists(vray_node): - control_node = vray_node current_width = lib.get_attr_in_layer( - "{}.width".format(control_node), layer=layer) + "{}.width".format(vray_node), layer=layer) current_height = lib.get_attr_in_layer( - "{}.height".format(control_node), layer=layer) + "{}.height".format(vray_node), layer=layer) current_pixelAspect = lib.get_attr_in_layer( - "{}.pixelAspect".format(control_node), layer=layer + "{}.pixelAspect".format(vray_node), layer=layer ) else: - raise PublishValidationError( - "Can't set VRay resolution because there is no node " - "named: `%s`" % vray_node) + invalid = self.log.error( + "Can't detect VRay resolution because there is no node " + "named: `{}`".format(vray_node)) + invalids.append(invalid) else: current_width = lib.get_attr_in_layer( "defaultResolution.width", layer=layer) @@ -51,15 +60,18 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "defaultResolution.pixelAspect", layer=layer ) if current_width != width or current_height != height: - raise PublishValidationError( + invalid = self.log.error( "Render resolution {}x{} does not match asset resolution {}x{}".format( # noqa:E501 current_width, current_height, width, height )) + invalids.append("{0}\n".format(invalid)) if current_pixelAspect != pixelAspect: - raise PublishValidationError( + invalid = self.log.error( "Render pixel aspect {} does not match asset pixel aspect {}".format( # noqa:E501 current_pixelAspect, pixelAspect )) + invalids.append("{0}\n".format(invalid)) + return invalids def get_db_resolution(self, instance): asset_doc = instance.data["assetEntity"] @@ -71,11 +83,13 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, width = data["resolutionWidth"] height = data["resolutionHeight"] pixelAspect = data["pixelAspect"] - return int(width), int(height), int(pixelAspect) + return int(width), int(height), float(pixelAspect) # Defaults if not found in asset document or project document - return 1920, 1080, 1 + return 1920, 1080, 1.0 @classmethod def repair(cls, instance): - return reset_scene_resolution() + layer = instance.data["renderlayer"] + with lib.renderlayer(layer): + reset_scene_resolution() From 8d7664420fdabdf3fed6f0f572d627f12ac29551 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:50:33 +0800 Subject: [PATCH 224/327] hound --- .../maya/plugins/publish/validate_resolution.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index b1752aa4bd..8e761d8958 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -25,9 +25,12 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, return invalid = self.get_invalid_resolution(instance) if invalid: - raise PublishValidationError("issues occurred", description=( - "Wrong render resolution setting. Please use repair button to fix it.\n" - "If current renderer is vray, make sure vraySettings node has been created" + raise PublishValidationError( + "issues occurred", description=( + "Wrong render resolution setting. " + "Please use repair button to fix it.\n" + "If current renderer is V-Ray, " + "make sure vraySettings node has been created" )) def get_invalid_resolution(self, instance): @@ -62,7 +65,8 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, if current_width != width or current_height != height: invalid = self.log.error( "Render resolution {}x{} does not match asset resolution {}x{}".format( # noqa:E501 - current_width, current_height, width, height + current_width, current_height, + width, height )) invalids.append("{0}\n".format(invalid)) if current_pixelAspect != pixelAspect: From 8fc0c3b81f1e8bfa786e0fa9f71c6da5c9bc57e7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:54:15 +0800 Subject: [PATCH 225/327] hound --- .../maya/plugins/publish/validate_resolution.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 8e761d8958..f00b2329ed 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -27,10 +27,10 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, if invalid: raise PublishValidationError( "issues occurred", description=( - "Wrong render resolution setting. " - "Please use repair button to fix it.\n" - "If current renderer is V-Ray, " - "make sure vraySettings node has been created" + "Wrong render resolution setting. " + "Please use repair button to fix it.\n" + "If current renderer is V-Ray, " + "make sure vraySettings node has been created" )) def get_invalid_resolution(self, instance): @@ -52,7 +52,8 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, else: invalid = self.log.error( "Can't detect VRay resolution because there is no node " - "named: `{}`".format(vray_node)) + "named: `{}`".format(vray_node) + ) invalids.append(invalid) else: current_width = lib.get_attr_in_layer( @@ -63,12 +64,12 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "defaultResolution.pixelAspect", layer=layer ) if current_width != width or current_height != height: - invalid = self.log.error( + invalid = self.log.error( "Render resolution {}x{} does not match asset resolution {}x{}".format( # noqa:E501 current_width, current_height, width, height )) - invalids.append("{0}\n".format(invalid)) + invalids.append("{0}\n".format(invalid)) if current_pixelAspect != pixelAspect: invalid = self.log.error( "Render pixel aspect {} does not match asset pixel aspect {}".format( # noqa:E501 From 145716211d6c04fea0d0eb3c422b07c2d3edd300 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:55:32 +0800 Subject: [PATCH 226/327] hound --- .../hosts/maya/plugins/publish/validate_resolution.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index f00b2329ed..c920be4602 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -27,10 +27,10 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, if invalid: raise PublishValidationError( "issues occurred", description=( - "Wrong render resolution setting. " - "Please use repair button to fix it.\n" - "If current renderer is V-Ray, " - "make sure vraySettings node has been created" + "Wrong render resolution setting. " + "Please use repair button to fix it.\n" + "If current renderer is V-Ray, " + "make sure vraySettings node has been created" )) def get_invalid_resolution(self, instance): From 4dc4d665b05c77aa2bc69a517aae0389522bc4b4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:56:34 +0800 Subject: [PATCH 227/327] hound --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index c920be4602..237a0fa186 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -31,7 +31,7 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "Please use repair button to fix it.\n" "If current renderer is V-Ray, " "make sure vraySettings node has been created" - )) + )) def get_invalid_resolution(self, instance): width, height, pixelAspect = self.get_db_resolution(instance) From 91d41c86c5310d5239ce5638dcb01c1c66b600b9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 19:57:35 +0800 Subject: [PATCH 228/327] hound --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 237a0fa186..fadb41302c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -30,8 +30,7 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "Wrong render resolution setting. " "Please use repair button to fix it.\n" "If current renderer is V-Ray, " - "make sure vraySettings node has been created" - )) + "make sure vraySettings node has been created")) def get_invalid_resolution(self, instance): width, height, pixelAspect = self.get_db_resolution(instance) From 26e0cacd3a676d085ff28719a6c52176d1757253 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 14:03:15 +0200 Subject: [PATCH 229/327] removing test scrips --- .../utility_scripts/tests/test_otio_as_edl.py | 49 ------------- .../testing_create_timeline_item_from_path.py | 73 ------------------- .../tests/testing_load_media_pool_item.py | 24 ------ .../tests/testing_startup_script.py | 5 -- .../tests/testing_timeline_op.py | 13 ---- 5 files changed, 164 deletions(-) delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py delete mode 100644 openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py diff --git a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py b/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py deleted file mode 100644 index 92f2e43a72..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py +++ /dev/null @@ -1,49 +0,0 @@ -#! python3 -import os -import sys - -import opentimelineio as otio - -from openpype.pipeline import install_host - -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.testing_utils import TestGUI -from openpype.hosts.resolve.otio import davinci_export as otio_export - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - project = bmdvr.get_current_project() - otio_timeline = otio_export.create_otio_timeline(project) - print(f"_ otio_timeline: `{otio_timeline}`") - edl_path = os.path.join(self.input_dir_path, "this_file_name.edl") - print(f"_ edl_path: `{edl_path}`") - # xml_string = otio_adapters.fcpx_xml.write_to_string(otio_timeline) - # print(f"_ xml_string: `{xml_string}`") - otio.adapters.write_to_file( - otio_timeline, edl_path, adapter_name="cmx_3600") - project = bmdvr.get_current_project() - media_pool = project.GetMediaPool() - timeline = media_pool.ImportTimelineFromFile(edl_path) - # at the end close the window - self._close_window(None) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py b/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py deleted file mode 100644 index 91a361ec08..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py +++ /dev/null @@ -1,73 +0,0 @@ -#! python3 -import os -import sys - -import clique - -from openpype.pipeline import install_host -from openpype.hosts.resolve.api.testing_utils import TestGUI -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - - self.dir_processing(self.input_dir_path) - - # at the end close the window - self._close_window(None) - - def dir_processing(self, dir_path): - collections, reminders = clique.assemble(os.listdir(dir_path)) - - # process reminders - for _rem in reminders: - _rem_path = os.path.join(dir_path, _rem) - - # go deeper if directory - if os.path.isdir(_rem_path): - print(_rem_path) - self.dir_processing(_rem_path) - else: - self.file_processing(_rem_path) - - # process collections - for _coll in collections: - _coll_path = os.path.join(dir_path, list(_coll).pop()) - self.file_processing(_coll_path) - - def file_processing(self, fpath): - print(f"_ fpath: `{fpath}`") - _base, ext = os.path.splitext(fpath) - # skip if unwanted extension - if ext not in self.extensions: - return - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py b/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py deleted file mode 100644 index 2e83188bde..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py +++ /dev/null @@ -1,24 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -def file_processing(fpath): - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - path = "C:/CODE/__openpype_projects/jtest03dev/shots/sq01/mainsq01sh030/publish/plate/plateMain/v006/jt3d_mainsq01sh030_plateMain_v006.0996.exr" - - # activate resolve from openpype - install_host(bmdvr) - - file_processing(path) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py b/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py deleted file mode 100644 index b64714ab16..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py +++ /dev/null @@ -1,5 +0,0 @@ -#! python3 -from openpype.hosts.resolve.startup import main - -if __name__ == "__main__": - main() diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py b/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py deleted file mode 100644 index 8270496f64..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py +++ /dev/null @@ -1,13 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import get_current_project - -if __name__ == "__main__": - install_host(bmdvr) - project = get_current_project() - timeline_count = project.GetTimelineCount() - print(f"Timeline count: {timeline_count}") - timeline = project.GetTimelineByIndex(timeline_count) - print(f"Timeline name: {timeline.GetName()}") - print(timeline.GetTrackCount("video")) From 19840862426e74c6558803864ec212014ada186f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 14:03:38 +0200 Subject: [PATCH 230/327] finalizing update of importing clips with multiple frames --- openpype/hosts/resolve/api/lib.py | 43 +++++++++++++++++++++-- openpype/hosts/resolve/api/pipeline.py | 1 - openpype/hosts/resolve/api/plugin.py | 47 ++++++++++++++++++++------ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index fb4b08cc1e..8564a24ac1 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -2,6 +2,7 @@ import sys import json import re import os +import glob import contextlib from opentimelineio import opentime @@ -183,8 +184,14 @@ def create_bin(name: str, root: object = None) -> object: return media_pool.GetCurrentFolder() -def create_media_pool_item(fpath: str, - root: object = None) -> object: +def create_media_pool_item( + fpath: str, + frame_start: int, + frame_end: int, + handle_start: int, + handle_end: int, + root: object = None, +) -> object: """ Create media pool item. @@ -204,8 +211,38 @@ def create_media_pool_item(fpath: str, if existing_mpi: return existing_mpi + + files = [] + first_frame = frame_start - handle_start + last_frame = frame_end + handle_end + dir_path = os.path.dirname(fpath) + base_name = os.path.basename(fpath) + + # prepare glob pattern for searching + padding = len(str(last_frame)) + str_first_frame = str(first_frame).zfill(padding) + + # convert str_first_frame to glob pattern + # replace all digits with `?` and all other chars with `[char]` + # example: `0001` -> `????` + glob_pattern = re.sub(r"\d", "?", str_first_frame) + + # in filename replace number with glob pattern + # example: `filename.0001.exr` -> `filename.????.exr` + base_name = re.sub(str_first_frame, glob_pattern, base_name) + + # get all files in folder + for file in glob.glob(os.path.join(dir_path, base_name)): + files.append(file) + + # iterate all files and check if they exists + # if not then remove them from list + for file in files[:]: + if not os.path.exists(file): + files.remove(file) + # add all data in folder to media pool - media_pool_items = media_pool.ImportMedia(fpath) + media_pool_items = media_pool.ImportMedia(files) return media_pool_items.pop() if media_pool_items else False diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 899cb825bb..b379c7b2e0 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -117,7 +117,6 @@ def containerise(timeline_item, for k, v in data.items(): data_imprint.update({k: v}) - print("_ data_imprint: {}".format(data_imprint)) lib.set_timeline_item_pype_tag(timeline_item, data_imprint) return timeline_item diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index da5e649576..b4c03d6809 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -1,6 +1,5 @@ import re import uuid - import qargparse from qtpy import QtWidgets, QtCore @@ -393,16 +392,15 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def load(self): - # create project bin for the media to be imported into - self.active_bin = lib.create_bin(self.data["binPath"]) - + def _get_frame_data(self): # create mediaItem in active project bin # create clip media - - media_pool_item = lib.create_media_pool_item( - self.data["path"], self.active_bin) - _clip_property = media_pool_item.GetClipProperty + frame_start = self.data["versionData"].get("frameStart") + frame_end = self.data["versionData"].get("frameEnd") + if frame_start is None: + frame_start = int(self.data["assetData"]["frameStart"]) + if frame_end is None: + frame_end = int(self.data["assetData"]["frameEnd"]) # get handles handle_start = self.data["versionData"].get("handleStart") @@ -412,6 +410,26 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) + return frame_start, frame_end, handle_start, handle_end + + def load(self): + # create project bin for the media to be imported into + self.active_bin = lib.create_bin(self.data["binPath"]) + + frame_start, frame_end, handle_start, handle_end = \ + self._get_frame_data() + + media_pool_item = lib.create_media_pool_item( + self.data["path"], + frame_start, + frame_end, + handle_start, + handle_end, + self.active_bin + ) + _clip_property = media_pool_item.GetClipProperty + + source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -435,10 +453,19 @@ class ClipLoader: # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) + frame_start, frame_end, handle_start, handle_end = \ + self._get_frame_data() + # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( - self.data["path"], self.active_bin) + self.data["path"], + frame_start, + frame_end, + handle_start, + handle_end, + self.active_bin + ) _clip_property = media_pool_item.GetClipProperty source_in = int(_clip_property("Start")) From 5262c0c7acab605ccecbd13357e58b8666d0f2f9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 20:29:56 +0800 Subject: [PATCH 231/327] tweaks on get_invalid_resolution --- .../plugins/publish/validate_resolution.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index fadb41302c..38deca9ecf 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -34,10 +34,9 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, def get_invalid_resolution(self, instance): width, height, pixelAspect = self.get_db_resolution(instance) - current_renderer = cmds.getAttr( - "defaultRenderGlobals.currentRenderer") + current_renderer = instance.data["renderer"] layer = instance.data["renderlayer"] - invalids = [] + invalid = False if current_renderer == "vray": vray_node = "vraySettings" if cmds.objExists(vray_node): @@ -49,11 +48,11 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "{}.pixelAspect".format(vray_node), layer=layer ) else: - invalid = self.log.error( + self.log.error( "Can't detect VRay resolution because there is no node " "named: `{}`".format(vray_node) ) - invalids.append(invalid) + invalid = True else: current_width = lib.get_attr_in_layer( "defaultResolution.width", layer=layer) @@ -63,19 +62,21 @@ class ValidateSceneResolution(pyblish.api.InstancePlugin, "defaultResolution.pixelAspect", layer=layer ) if current_width != width or current_height != height: - invalid = self.log.error( - "Render resolution {}x{} does not match asset resolution {}x{}".format( # noqa:E501 + self.log.error( + "Render resolution {}x{} does not match " + "asset resolution {}x{}".format( current_width, current_height, width, height )) - invalids.append("{0}\n".format(invalid)) + invalid = True if current_pixelAspect != pixelAspect: - invalid = self.log.error( - "Render pixel aspect {} does not match asset pixel aspect {}".format( # noqa:E501 + self.log.error( + "Render pixel aspect {} does not match " + "asset pixel aspect {}".format( current_pixelAspect, pixelAspect )) - invalids.append("{0}\n".format(invalid)) - return invalids + invalid = True + return invalid def get_db_resolution(self, instance): asset_doc = instance.data["assetEntity"] From b4c0f2880a32f5e9e7a56597307894bbe63f24c0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 20:35:38 +0800 Subject: [PATCH 232/327] add validate resolution as parts of maya settings --- .../deadline/plugins/publish/submit_publish_job.py | 2 +- openpype/settings/defaults/project_settings/maya.json | 5 +++++ .../projects_schema/schemas/schema_maya_publish.json | 4 ++++ server_addon/maya/server/settings/publishers.py | 9 +++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 6ed5819f2b..57ce8c438f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -321,7 +321,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) - response = requests.post(url, json=payload, timeout=10) + response = requests.post(url, json=payload, timeout=10, verify=False) if not response.ok: raise Exception(response.text) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 300d63985b..7719a5e255 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -829,6 +829,11 @@ "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": true, "optional": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 8a0815c185..d2e7c51e24 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -431,6 +431,10 @@ "type": "schema_template", "name": "template_publish_plugin", "template_data": [ + { + "key": "ValidateResolution", + "label": "Validate Resolution Settings" + }, { "key": "ValidateCurrentRenderLayerIsRenderable", "label": "Validate Current Render Layer Has Renderable Camera" diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index 6c5baa3900..dd8d4a0a37 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -433,6 +433,10 @@ class PublishersModel(BaseSettingsModel): default_factory=ValidateRenderSettingsModel, title="Validate Render Settings" ) + ValidateResolution: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Resolution Setting" + ) ValidateCurrentRenderLayerIsRenderable: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Current Render Layer Has Renderable Camera" @@ -902,6 +906,11 @@ DEFAULT_PUBLISH_SETTINGS = { "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": True, "optional": False, From 13c9aec4a7b8a58e4f03bd6fa20462c051aaaf3c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 20:59:40 +0800 Subject: [PATCH 233/327] Rename ValidateSceneResolution to ValidateResolution --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 38deca9ecf..66962afce5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -9,8 +9,8 @@ from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.lib import reset_scene_resolution -class ValidateSceneResolution(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): +class ValidateResolution(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validate the render resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 From 64b03447f128b3b564441050edfed23bf2926cd5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 21:01:46 +0800 Subject: [PATCH 234/327] restore unrelated code --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 57ce8c438f..6ed5819f2b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -321,7 +321,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) - response = requests.post(url, json=payload, timeout=10, verify=False) + response = requests.post(url, json=payload, timeout=10) if not response.ok: raise Exception(response.text) From b2588636e9970d94a6537a2ac4a735a03978ee9c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 15:11:19 +0200 Subject: [PATCH 235/327] add removing of media pool item for clip remove. no way to remove timeline item so they stay offline at timeline --- openpype/hosts/resolve/api/lib.py | 6 ++++++ openpype/hosts/resolve/plugins/load/load_clip.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 8564a24ac1..5d80866e6a 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -184,6 +184,12 @@ def create_bin(name: str, root: object = None) -> object: return media_pool.GetCurrentFolder() +def remove_media_pool_item(media_pool_item: object) -> bool: + print(media_pool_item) + media_pool = get_current_project().GetMediaPool() + return media_pool.DeleteClips([media_pool_item]) + + def create_media_pool_item( fpath: str, frame_start: int, diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index eea44a3726..fd181bae41 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -163,3 +163,10 @@ class LoadClip(plugin.TimelineItemLoader): timeline_item.SetClipColor(cls.clip_color_last) else: timeline_item.SetClipColor(cls.clip_color) + + def remove(self, container): + namespace = container['namespace'] + timeline_item = lib.get_pype_timeline_item_by_name(namespace) + take_mp_item = timeline_item.GetMediaPoolItem() + + lib.remove_media_pool_item(take_mp_item) From c7df127becf48474494f59087900c4aceaa39e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 6 Oct 2023 15:19:27 +0200 Subject: [PATCH 236/327] Update openpype/hosts/resolve/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/resolve/api/lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 5d80866e6a..c3ab1a263b 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -243,9 +243,7 @@ def create_media_pool_item( # iterate all files and check if they exists # if not then remove them from list - for file in files[:]: - if not os.path.exists(file): - files.remove(file) + files = [f for f in files if os.path.exists(f)] # add all data in folder to media pool media_pool_items = media_pool.ImportMedia(files) From 446ee7983113c5e36578ab9650d99d81a566a1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 6 Oct 2023 15:19:35 +0200 Subject: [PATCH 237/327] Update openpype/hosts/resolve/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/resolve/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index c3ab1a263b..4d186e199d 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -241,8 +241,7 @@ def create_media_pool_item( for file in glob.glob(os.path.join(dir_path, base_name)): files.append(file) - # iterate all files and check if they exists - # if not then remove them from list + # keep only existing files files = [f for f in files if os.path.exists(f)] # add all data in folder to media pool From 5ac109a7aeaca0a8797a0c43c81d51e6957bc2ce Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Oct 2023 15:21:04 +0200 Subject: [PATCH 238/327] :art: add task name option --- .../plugins/create/create_multishot_layout.py | 69 +++++++++++++------ 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 706203bdab..d0c4137ac4 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -1,20 +1,24 @@ -from ayon_api import get_folder_by_name, get_folder_by_path, get_folders +from ayon_api import ( + get_folder_by_name, + get_folder_by_path, + get_folders, +) from maya import cmds # noqa: F401 from openpype import AYON_SERVER_ENABLED from openpype.client import get_assets from openpype.hosts.maya.api import plugin -from openpype.lib import BoolDef, EnumDef +from openpype.lib import BoolDef, EnumDef, TextDef from openpype.pipeline import ( Creator, get_current_asset_name, - get_current_project_name + get_current_project_name, ) from openpype.pipeline.create import CreatorError class CreateMultishotLayout(plugin.MayaCreator): - """Create a multishot layout in the Maya scene. + """Create a multi-shot layout in the Maya scene. This creator will create a Camera Sequencer in the Maya scene based on the shots found under the specified folder. The shots will be added to @@ -23,7 +27,7 @@ class CreateMultishotLayout(plugin.MayaCreator): """ identifier = "io.openpype.creators.maya.multishotlayout" - label = "Multishot Layout" + label = "Multi-shot Layout" family = "layout" icon = "project-diagram" @@ -46,16 +50,19 @@ class CreateMultishotLayout(plugin.MayaCreator): folder_name=get_current_asset_name(), ) + current_path_parts = current_folder["path"].split("/") + items_with_label = [ - dict(label=p if p != current_folder["name"] else f"{p} (current)", - value=str(p)) - for p in current_folder["path"].split("/") + dict( + label=current_path_parts[p] if current_path_parts[p] != current_folder["name"] else f"{current_path_parts[p]} (current)", # noqa + value="/".join(current_path_parts[:p+1]), + ) + for p in range(len(current_path_parts)) ] - items_with_label.insert(0, - dict(label=f"{self.project_name} " - "(shots directly under the project)", - value=None)) + items_with_label.insert( + 0, dict(label=f"{self.project_name} " + "(shots directly under the project)", value="")) return [ EnumDef("shotParent", @@ -67,7 +74,12 @@ class CreateMultishotLayout(plugin.MayaCreator): label="Group Loaded Assets", tooltip="Enable this when you want to publish group of " "loaded asset", - default=False) + default=False), + TextDef("taskName", + label="Associated Task Name", + tooltip=("Task name to be associated " + "with the created Layout"), + default="layout"), ] def create(self, subset_name, instance_data, pre_create_data): @@ -98,6 +110,18 @@ class CreateMultishotLayout(plugin.MayaCreator): if not shot["active"]: continue + # get task for shot + asset_doc = next( + asset_doc for asset_doc in op_asset_docs + if asset_doc["_id"] == shot["id"] + + ) + + tasks = list(asset_doc.get("data").get("tasks").keys()) + layout_task = None + if pre_create_data["taskName"] in tasks: + layout_task = pre_create_data["taskName"] + shot_name = f"{shot['name']}%s" % ( f" ({shot['label']})" if shot["label"] else "") cmds.shot(sst=shot["attrib"]["clipIn"], @@ -105,18 +129,21 @@ class CreateMultishotLayout(plugin.MayaCreator): shotName=shot_name) # Create layout instance by the layout creator + + instance_data = { + "asset": shot["name"], + "variant": layout_creator.get_default_variant() + } + if layout_task: + instance_data["task"] = layout_task + layout_creator.create( subset_name=layout_creator.get_subset_name( - self.get_default_variant(), + layout_creator.get_default_variant(), self.create_context.get_current_task_name(), - next( - asset_doc for asset_doc in op_asset_docs - if asset_doc["_id"] == shot["id"] - ), + asset_doc, self.project_name), - instance_data={ - "asset": shot["name"], - }, + instance_data=instance_data, pre_create_data={ "groupLoadedAssets": pre_create_data["groupLoadedAssets"] } From d4d48aacf894ac1e893b97d4c4a2c6b749c201e1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 15:30:44 +0200 Subject: [PATCH 239/327] removing debugging print. --- openpype/hosts/resolve/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 5d80866e6a..0f24a71cff 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -185,7 +185,6 @@ def create_bin(name: str, root: object = None) -> object: def remove_media_pool_item(media_pool_item: object) -> bool: - print(media_pool_item) media_pool = get_current_project().GetMediaPool() return media_pool.DeleteClips([media_pool_item]) From 435ff3389f73ce0c0f39f5c1642ce61bd40d7bf6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Oct 2023 15:39:35 +0200 Subject: [PATCH 240/327] :dog: calm the hound --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index d0c4137ac4..90a6b08134 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -55,7 +55,7 @@ class CreateMultishotLayout(plugin.MayaCreator): items_with_label = [ dict( label=current_path_parts[p] if current_path_parts[p] != current_folder["name"] else f"{current_path_parts[p]} (current)", # noqa - value="/".join(current_path_parts[:p+1]), + value="/".join(current_path_parts[:p + 1]), ) for p in range(len(current_path_parts)) ] From c51ed6409c27b017c357a0ccf91016103b6850d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 15:51:47 +0200 Subject: [PATCH 241/327] removing also timeline item --- openpype/hosts/resolve/plugins/load/load_clip.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index fd181bae41..e9e83ad05d 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -168,5 +168,9 @@ class LoadClip(plugin.TimelineItemLoader): namespace = container['namespace'] timeline_item = lib.get_pype_timeline_item_by_name(namespace) take_mp_item = timeline_item.GetMediaPoolItem() + timeline = lib.get_current_timeline() + + if timeline.DeleteClips is not None: + timeline.DeleteClips([timeline_item]) lib.remove_media_pool_item(take_mp_item) From ff7af16fdda73c9614a4b324da91d54ba6caaa35 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 6 Oct 2023 15:20:42 +0100 Subject: [PATCH 242/327] Added animation family for alembic loader --- openpype/hosts/blender/plugins/load/load_abc.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 292925c833..a1779b7778 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -26,8 +26,7 @@ class CacheModelLoader(plugin.AssetLoader): Note: At least for now it only supports Alembic files. """ - - families = ["model", "pointcache"] + families = ["model", "pointcache", "animation"] representations = ["abc"] label = "Load Alembic" @@ -61,8 +60,6 @@ class CacheModelLoader(plugin.AssetLoader): relative_path=relative ) - parent = bpy.context.scene.collection - imported = lib.get_selection() # Children must be linked before parents, @@ -90,13 +87,15 @@ class CacheModelLoader(plugin.AssetLoader): material_slot.material.name = f"{group_name}:{name_mat}" if not obj.get(AVALON_PROPERTY): - obj[AVALON_PROPERTY] = dict() + obj[AVALON_PROPERTY] = {} avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) plugin.deselect_all() + collection.objects.link(asset_group) + return objects def process_asset( @@ -131,8 +130,6 @@ class CacheModelLoader(plugin.AssetLoader): objects = self._process(libpath, asset_group, group_name) - bpy.context.scene.collection.objects.link(asset_group) - asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, From 787a0d1847e7aa5e78b5bc51d13cf68f31dd1379 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 6 Oct 2023 15:47:34 +0100 Subject: [PATCH 243/327] Fix issues with the collections where the objects are linked to --- .../hosts/blender/plugins/load/load_abc.py | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index a1779b7778..1442e65f68 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -52,8 +52,6 @@ class CacheModelLoader(plugin.AssetLoader): def _process(self, libpath, asset_group, group_name): plugin.deselect_all() - collection = bpy.context.view_layer.active_layer_collection.collection - relative = bpy.context.preferences.filepaths.use_relative_paths bpy.ops.wm.alembic_import( filepath=libpath, @@ -76,6 +74,10 @@ class CacheModelLoader(plugin.AssetLoader): objects.reverse() for obj in objects: + # Unlink the object from all collections + collections = obj.users_collection + for collection in collections: + collection.objects.unlink(obj) name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': @@ -94,8 +96,6 @@ class CacheModelLoader(plugin.AssetLoader): plugin.deselect_all() - collection.objects.link(asset_group) - return objects def process_asset( @@ -130,6 +130,21 @@ class CacheModelLoader(plugin.AssetLoader): objects = self._process(libpath, asset_group, group_name) + # Link the asset group to the active collection + collection = bpy.context.view_layer.active_layer_collection.collection + collection.objects.link(asset_group) + + # Link the imported objects to any collection where the asset group is + # linked to, except the AVALON_CONTAINERS collection + group_collections = [ + collection + for collection in asset_group.users_collection + if collection != avalon_containers] + + for obj in objects: + for collection in group_collections: + collection.objects.link(obj) + asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, @@ -204,7 +219,20 @@ class CacheModelLoader(plugin.AssetLoader): mat = asset_group.matrix_basis.copy() self._remove(asset_group) - self._process(str(libpath), asset_group, object_name) + objects = self._process(str(libpath), asset_group, object_name) + + # Link the imported objects to any collection where the asset group is + # linked to, except the AVALON_CONTAINERS collection + avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS) + group_collections = [ + collection + for collection in asset_group.users_collection + if collection != avalon_containers] + + for obj in objects: + for collection in group_collections: + collection.objects.link(obj) + asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) From dd46d48ffc1fcec9116d27b4aa7ce437db5e3ac2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 22:50:16 +0800 Subject: [PATCH 244/327] upversion for maya server addon & fix on repair action in validate resolution --- .../plugins/publish/validate_resolution.py | 46 +++++++++++++------ server_addon/maya/server/version.py | 2 +- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 66962afce5..092860164f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -13,7 +13,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the render resolution setting aligned with DB""" - order = pyblish.api.ValidatorOrder - 0.01 + order = pyblish.api.ValidatorOrder families = ["renderlayer"] hosts = ["maya"] label = "Validate Resolution" @@ -26,14 +26,17 @@ class ValidateResolution(pyblish.api.InstancePlugin, invalid = self.get_invalid_resolution(instance) if invalid: raise PublishValidationError( - "issues occurred", description=( + "Render resolution is invalid. See log for details.", + description=( "Wrong render resolution setting. " "Please use repair button to fix it.\n" "If current renderer is V-Ray, " - "make sure vraySettings node has been created")) - - def get_invalid_resolution(self, instance): - width, height, pixelAspect = self.get_db_resolution(instance) + "make sure vraySettings node has been created" + ) + ) + @classmethod + def get_invalid_resolution(cls, instance): + width, height, pixelAspect = cls.get_db_resolution(instance) current_renderer = instance.data["renderer"] layer = instance.data["renderlayer"] invalid = False @@ -48,11 +51,11 @@ class ValidateResolution(pyblish.api.InstancePlugin, "{}.pixelAspect".format(vray_node), layer=layer ) else: - self.log.error( + cls.log.error( "Can't detect VRay resolution because there is no node " "named: `{}`".format(vray_node) ) - invalid = True + return True else: current_width = lib.get_attr_in_layer( "defaultResolution.width", layer=layer) @@ -62,7 +65,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, "defaultResolution.pixelAspect", layer=layer ) if current_width != width or current_height != height: - self.log.error( + cls.log.error( "Render resolution {}x{} does not match " "asset resolution {}x{}".format( current_width, current_height, @@ -70,7 +73,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, )) invalid = True if current_pixelAspect != pixelAspect: - self.log.error( + cls.log.error( "Render pixel aspect {} does not match " "asset pixel aspect {}".format( current_pixelAspect, pixelAspect @@ -78,12 +81,15 @@ class ValidateResolution(pyblish.api.InstancePlugin, invalid = True return invalid - def get_db_resolution(self, instance): + @classmethod + def get_db_resolution(cls, instance): asset_doc = instance.data["assetEntity"] project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: - if "resolutionWidth" in data and ( - "resolutionHeight" in data and "pixelAspect" in data + if ( + "resolutionWidth" in data and + "resolutionHeight" in data and + "pixelAspect" in data ): width = data["resolutionWidth"] height = data["resolutionHeight"] @@ -95,6 +101,16 @@ class ValidateResolution(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): - layer = instance.data["renderlayer"] - with lib.renderlayer(layer): + # Usually without renderlayer overrides the renderlayers + # all share the same resolution value - so fixing the first + # will have fixed all the others too. It's much faster to + # check whether it's invalid first instead of switching + # into all layers individually + if not cls.get_invalid_resolution(instance): + cls.log.debug( + "Nothing to repair on instance: {}".format(instance) + ) + return + layer_node = instance.data['setMembers'] + with lib.renderlayer(layer_node): reset_scene_resolution() diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index de699158fd..90ce344d3e 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.4" +__version__ = "0.1.5" From 1d531b1ad5c94e4843c0d245fd61fa8372473ee1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 6 Oct 2023 22:54:16 +0800 Subject: [PATCH 245/327] hound --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 092860164f..b214f87906 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -34,6 +34,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, "make sure vraySettings node has been created" ) ) + @classmethod def get_invalid_resolution(cls, instance): width, height, pixelAspect = cls.get_db_resolution(instance) From e03fe24a07126403b384f5cbae18653d55111356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 6 Oct 2023 18:18:28 +0200 Subject: [PATCH 246/327] Update openpype/hosts/resolve/plugins/load/load_clip.py Co-authored-by: Roy Nieterau --- openpype/hosts/resolve/plugins/load/load_clip.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index e9e83ad05d..799b85ea7f 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -170,6 +170,9 @@ class LoadClip(plugin.TimelineItemLoader): take_mp_item = timeline_item.GetMediaPoolItem() timeline = lib.get_current_timeline() + # DeleteClips function was added in Resolve 18.5+ + # by checking None we can detect whether the + # function exists in Resolve if timeline.DeleteClips is not None: timeline.DeleteClips([timeline_item]) From 931d847f891af55c2f8d10ed29cca8c853b96d8a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Oct 2023 18:32:29 +0200 Subject: [PATCH 247/327] :recycle: fix readability of the code --- .../plugins/create/create_multishot_layout.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 90a6b08134..6ff40851e3 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -52,14 +52,31 @@ class CreateMultishotLayout(plugin.MayaCreator): current_path_parts = current_folder["path"].split("/") - items_with_label = [ - dict( - label=current_path_parts[p] if current_path_parts[p] != current_folder["name"] else f"{current_path_parts[p]} (current)", # noqa - value="/".join(current_path_parts[:p + 1]), - ) - for p in range(len(current_path_parts)) - ] + items_with_label = [] + # populate the list with parents of the current folder + # this will create menu items like: + # [ + # { + # "value": "", + # "label": "project (shots directly under the project)" + # }, { + # "value": "shots/shot_01", "label": "shot_01 (current)" + # }, { + # "value": "shots", "label": "shots" + # } + # ] + # go through the current folder path and add each part to the list, + # but mark the current folder. + for part_idx in range(len(current_path_parts)): + label = current_path_parts[part_idx] + if current_path_parts[part_idx] == current_folder["name"]: + label = f"{current_path_parts[part_idx]} (current)" + items_with_label.append( + dict(label=label, + value="/".join(current_path_parts[:part_idx + 1])) + ) + # add the project as the first item items_with_label.insert( 0, dict(label=f"{self.project_name} " "(shots directly under the project)", value="")) From 32052551e2c3848da47a3991c3eda985129f9059 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 7 Oct 2023 03:24:54 +0000 Subject: [PATCH 248/327] [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 399c1404b1..01c000e54d 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2-nightly.2" +__version__ = "3.17.2-nightly.3" From 25c023290f6223e907d0b8a1879931ac528de23d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Oct 2023 03:25:38 +0000 Subject: [PATCH 249/327] 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 e3ca8262e5..78bea3d838 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.17.2-nightly.3 - 3.17.2-nightly.2 - 3.17.2-nightly.1 - 3.17.1 @@ -134,7 +135,6 @@ body: - 3.14.10 - 3.14.10-nightly.9 - 3.14.10-nightly.8 - - 3.14.10-nightly.7 validations: required: true - type: dropdown From 2932debbaf959df7f54856c88c0757ac14d5aa78 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 7 Oct 2023 19:45:23 +0200 Subject: [PATCH 250/327] Cleanup + fix updating/remove logic - Use container `_timeline_item` to ensure we act on the expected timeline item - otherwise `lib.get_pype_timeline_item_by_name` can take the wrong one if the same subset is loaded more than once which made update/remove actually pick an unexpected timeline item. - On update, remove media pool item if previous version now has no usage - On remove, only remove media pool item if it has no usage - Don't duplicate logic to define version data to put in tag data, now uses a `get_tag_data` method - Don't create a `fake context` but use the `get_representation_context` to get the context on load to ensure whatever uses it has the correct context. --- .../hosts/resolve/plugins/load/load_clip.py | 106 +++++++----------- 1 file changed, 42 insertions(+), 64 deletions(-) diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index e9e83ad05d..8c702a4dfc 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -1,12 +1,7 @@ -from copy import deepcopy - -from openpype.client import ( - get_version_by_id, - get_last_version_by_subset_id, -) -# from openpype.hosts import resolve +from openpype.client import get_last_version_by_subset_id from openpype.pipeline import ( get_representation_path, + get_representation_context, get_current_project_name, ) from openpype.hosts.resolve.api import lib, plugin @@ -53,37 +48,11 @@ class LoadClip(plugin.TimelineItemLoader): timeline_item = plugin.ClipLoader( self, context, path, **options).load() namespace = namespace or timeline_item.GetName() - version = context['version'] - version_data = version.get("data", {}) - version_name = version.get("name", None) - colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) - - # add additional metadata from the version to imprint Avalon knob - add_keys = [ - "frameStart", "frameEnd", "source", "author", - "fps", "handleStart", "handleEnd" - ] - - # move all version data keys to tag data - data_imprint = {} - for key in add_keys: - data_imprint.update({ - key: version_data.get(key, str(None)) - }) - - # add variables related to version context - data_imprint.update({ - "version": version_name, - "colorspace": colorspace, - "objectName": object_name - }) # update color of clip regarding the version order - self.set_item_color(timeline_item, version) - - self.log.info("Loader done: `{}`".format(name)) + self.set_item_color(timeline_item, version=context["version"]) + data_imprint = self.get_tag_data(context, name, namespace) return containerise( timeline_item, name, namespace, context, @@ -97,53 +66,60 @@ class LoadClip(plugin.TimelineItemLoader): """ Updating previously loaded clips """ - # load clip to timeline and get main variables - context = deepcopy(representation["context"]) - context.update({"representation": representation}) + context = get_representation_context(representation) name = container['name'] namespace = container['namespace'] - timeline_item = lib.get_pype_timeline_item_by_name(namespace) + timeline_item = container["_timeline_item"] - project_name = get_current_project_name() - version = get_version_by_id(project_name, representation["parent"]) + media_pool_item = timeline_item.GetMediaPoolItem() + + path = get_representation_path(representation) + loader = plugin.ClipLoader(self, context, path) + timeline_item = loader.update(timeline_item) + + # update color of clip regarding the version order + self.set_item_color(timeline_item, version=context["version"]) + + # if original media pool item has no remaining usages left + # remove it from the media pool + if int(media_pool_item.GetClipProperty("Usage")) == 0: + lib.remove_media_pool_item(media_pool_item) + + data_imprint = self.get_tag_data(context, name, namespace) + return update_container(timeline_item, data_imprint) + + def get_tag_data(self, context, name, namespace): + """Return data to be imprinted on the timeline item marker""" + + representation = context["representation"] + version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) - path = get_representation_path(representation) - - context["version"] = {"data": version_data} - loader = plugin.ClipLoader(self, context, path) - timeline_item = loader.update(timeline_item) # add additional metadata from the version to imprint Avalon knob - add_keys = [ + # move all version data keys to tag data + add_version_data_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] - - # move all version data keys to tag data - data_imprint = {} - for key in add_keys: - data_imprint.update({ - key: version_data.get(key, str(None)) - }) + data = { + key: version_data.get(key, "None") for key in add_version_data_keys + } # add variables related to version context - data_imprint.update({ + data.update({ "representation": str(representation["_id"]), "version": version_name, "colorspace": colorspace, "objectName": object_name }) - - # update color of clip regarding the version order - self.set_item_color(timeline_item, version) - - return update_container(timeline_item, data_imprint) + return data @classmethod def set_item_color(cls, timeline_item, version): + """Color timeline item based on whether it is outdated or latest""" # define version name version_name = version.get("name", None) # get all versions in list @@ -165,12 +141,14 @@ class LoadClip(plugin.TimelineItemLoader): timeline_item.SetClipColor(cls.clip_color) def remove(self, container): - namespace = container['namespace'] - timeline_item = lib.get_pype_timeline_item_by_name(namespace) - take_mp_item = timeline_item.GetMediaPoolItem() + timeline_item = container["_timeline_item"] + media_pool_item = timeline_item.GetMediaPoolItem() timeline = lib.get_current_timeline() if timeline.DeleteClips is not None: timeline.DeleteClips([timeline_item]) - lib.remove_media_pool_item(take_mp_item) + # if media pool item has no remaining usages left + # remove it from the media pool + if int(media_pool_item.GetClipProperty("Usage")) == 0: + lib.remove_media_pool_item(media_pool_item) From bb74f9b3ba7a9dea03dcd8451d7ec9d12ffbe92b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 7 Oct 2023 19:45:49 +0200 Subject: [PATCH 251/327] Cosmetics --- openpype/hosts/resolve/api/pipeline.py | 3 +-- openpype/hosts/resolve/api/plugin.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 28be387ce9..93dec300fb 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -127,8 +127,7 @@ def containerise(timeline_item, }) if data: - for k, v in data.items(): - data_imprint.update({k: v}) + data_imprint.update(data) lib.set_timeline_item_pype_tag(timeline_item, data_imprint) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index b4c03d6809..85245a5d12 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -338,8 +338,6 @@ class ClipLoader: else: self.active_timeline = lib.get_current_timeline() - - def _populate_data(self): """ Gets context and convert it to self.data data structure: @@ -429,7 +427,6 @@ class ClipLoader: ) _clip_property = media_pool_item.GetClipProperty - source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) From 708aef05375ae41109e260797ff23fe9f9aa4097 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 7 Oct 2023 20:02:10 +0200 Subject: [PATCH 252/327] Code cosmetics --- openpype/hosts/resolve/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 3139c32093..942caca72a 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -580,11 +580,11 @@ def set_pype_marker(timeline_item, tag_data): def get_pype_marker(timeline_item): timeline_item_markers = timeline_item.GetMarkers() - for marker_frame in timeline_item_markers: - note = timeline_item_markers[marker_frame]["note"] - color = timeline_item_markers[marker_frame]["color"] - name = timeline_item_markers[marker_frame]["name"] + for marker_frame, marker in timeline_item_markers.items(): + color = marker["color"] + name = marker["name"] if name == self.pype_marker_name and color == self.pype_marker_color: + note = marker["note"] self.temp_marker_frame = marker_frame return json.loads(note) From 26bbb702df9eaa9c86117e6dbe7654268f4e590a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 7 Oct 2023 20:04:21 +0200 Subject: [PATCH 253/327] Implement legacy logic where we remove the pype tag in older versions of Resolve - Unfortunately due to API limitations cannot remove the TimelineItem from the Timeline in old versions of Resolve --- openpype/hosts/resolve/plugins/load/load_clip.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index a17db376be..5e81441332 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -150,6 +150,15 @@ class LoadClip(plugin.TimelineItemLoader): # function exists in Resolve if timeline.DeleteClips is not None: timeline.DeleteClips([timeline_item]) + else: + # Resolve versions older than 18.5 can't delete clips via API + # so all we can do is just remove the pype marker to 'untag' it + if lib.get_pype_marker(timeline_item): + # Note: We must call `get_pype_marker` because + # `delete_pype_marker` uses a global variable set by + # `get_pype_marker` to delete the right marker + # TODO: Improve code to avoid the global `temp_marker_frame` + lib.delete_pype_marker(timeline_item) # if media pool item has no remaining usages left # remove it from the media pool From f863e3f0b41d244323c7c61688ecbf2f2a996e42 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 9 Oct 2023 10:43:15 +0200 Subject: [PATCH 254/327] change version regex to support blender 4 (#5723) --- openpype/hosts/blender/hooks/pre_pyside_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index 777e383215..2aa3a5e49a 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -31,7 +31,7 @@ class InstallPySideToBlender(PreLaunchHook): def inner_execute(self): # Get blender's python directory - version_regex = re.compile(r"^[2-3]\.[0-9]+$") + version_regex = re.compile(r"^[2-4]\.[0-9]+$") platform = system().lower() executable = self.launch_context.executable.executable_path From b711758e1f504030afe131bd517446a20d01b0fc Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 9 Oct 2023 10:14:03 +0100 Subject: [PATCH 255/327] Code improvements --- .../hosts/blender/plugins/load/load_abc.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 1442e65f68..9b3d940536 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -98,6 +98,18 @@ class CacheModelLoader(plugin.AssetLoader): return objects + def _link_objects(self, objects, collection, containers, asset_group): + # Link the imported objects to any collection where the asset group is + # linked to, except the AVALON_CONTAINERS collection + group_collections = [ + collection + for collection in asset_group.users_collection + if collection != containers] + + for obj in objects: + for collection in group_collections: + collection.objects.link(obj) + def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None @@ -119,14 +131,13 @@ class CacheModelLoader(plugin.AssetLoader): group_name = plugin.asset_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" - avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_containers: - avalon_containers = bpy.data.collections.new( - name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_containers) + containers = bpy.data.collections.get(AVALON_CONTAINERS) + if not containers: + containers = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) - avalon_containers.objects.link(asset_group) + containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) @@ -134,16 +145,7 @@ class CacheModelLoader(plugin.AssetLoader): collection = bpy.context.view_layer.active_layer_collection.collection collection.objects.link(asset_group) - # Link the imported objects to any collection where the asset group is - # linked to, except the AVALON_CONTAINERS collection - group_collections = [ - collection - for collection in asset_group.users_collection - if collection != avalon_containers] - - for obj in objects: - for collection in group_collections: - collection.objects.link(obj) + self._link_objects(objects, asset_group, containers, asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", @@ -221,17 +223,8 @@ class CacheModelLoader(plugin.AssetLoader): objects = self._process(str(libpath), asset_group, object_name) - # Link the imported objects to any collection where the asset group is - # linked to, except the AVALON_CONTAINERS collection - avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS) - group_collections = [ - collection - for collection in asset_group.users_collection - if collection != avalon_containers] - - for obj in objects: - for collection in group_collections: - collection.objects.link(obj) + containers = bpy.data.collections.get(AVALON_CONTAINERS) + self._link_objects(objects, asset_group, containers, asset_group) asset_group.matrix_basis = mat From 548ca106ad6ab8021f1c326ac375dd1dc42a3482 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 9 Oct 2023 17:14:21 +0800 Subject: [PATCH 256/327] paragraph tweaks on description for validator --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index b214f87906..4c3fbcddf0 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -29,7 +29,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, "Render resolution is invalid. See log for details.", description=( "Wrong render resolution setting. " - "Please use repair button to fix it.\n" + "Please use repair button to fix it.\n\n" "If current renderer is V-Ray, " "make sure vraySettings node has been created" ) From 60834f6997247823c2d1d0463809207cb39cffed Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 9 Oct 2023 17:18:34 +0800 Subject: [PATCH 257/327] hound --- openpype/hosts/maya/plugins/publish/validate_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py index 4c3fbcddf0..91b473b250 100644 --- a/openpype/hosts/maya/plugins/publish/validate_resolution.py +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -31,7 +31,7 @@ class ValidateResolution(pyblish.api.InstancePlugin, "Wrong render resolution setting. " "Please use repair button to fix it.\n\n" "If current renderer is V-Ray, " - "make sure vraySettings node has been created" + "make sure vraySettings node has been created." ) ) From 0a71b89ddd857676d1561c3cdbfeb690ebae6103 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 13:57:53 +0200 Subject: [PATCH 258/327] global: adding abstracted `get_representation_files` --- openpype/pipeline/__init__.py | 2 ++ openpype/pipeline/load/__init__.py | 2 ++ openpype/pipeline/load/utils.py | 50 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 8f370d389b..ca2a6bcf2c 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -48,6 +48,7 @@ from .load import ( loaders_from_representation, get_representation_path, get_representation_context, + get_representation_files, get_repres_contexts, ) @@ -152,6 +153,7 @@ __all__ = ( "loaders_from_representation", "get_representation_path", "get_representation_context", + "get_representation_files", "get_repres_contexts", # --- Publish --- diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 7320a9f0e8..c07388fd45 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -11,6 +11,7 @@ from .utils import ( get_contexts_for_repre_docs, get_subset_contexts, get_representation_context, + get_representation_files, load_with_repre_context, load_with_subset_context, @@ -64,6 +65,7 @@ __all__ = ( "get_contexts_for_repre_docs", "get_subset_contexts", "get_representation_context", + "get_representation_files", "load_with_repre_context", "load_with_subset_context", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index b10d6032b3..81175a8261 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -1,4 +1,6 @@ import os +import re +import glob import platform import copy import getpass @@ -286,6 +288,54 @@ def get_representation_context(representation): return context +def get_representation_files(context, filepath): + """Return list of files for representation. + + Args: + representation (dict): Representation document. + filepath (str): Filepath of the representation. + + Returns: + list[str]: List of files for representation. + """ + version = context["version"] + frame_start = version["data"]["frameStart"] + frame_end = version["data"]["frameEnd"] + handle_start = version["data"]["handleStart"] + handle_end = version["data"]["handleEnd"] + + first_frame = frame_start - handle_start + last_frame = frame_end + handle_end + dir_path = os.path.dirname(filepath) + base_name = os.path.basename(filepath) + + # prepare glob pattern for searching + padding = len(str(last_frame)) + str_first_frame = str(first_frame).zfill(padding) + + # convert str_first_frame to glob pattern + # replace all digits with `?` and all other chars with `[char]` + # example: `0001` -> `????` + glob_pattern = re.sub(r"\d", "?", str_first_frame) + + # in filename replace number with glob pattern + # example: `filename.0001.exr` -> `filename.????.exr` + base_name = re.sub(str_first_frame, glob_pattern, base_name) + + files = [] + # get all files in folder + for file in glob.glob(os.path.join(dir_path, base_name)): + files.append(file) + + # keep only existing files + files = [f for f in files if os.path.exists(f)] + + # sort files by frame number + files.sort(key=lambda f: int(re.findall(r"\d+", f)[-1])) + + return files + + def load_with_repre_context( Loader, repre_context, namespace=None, name=None, options=None, **kwargs ): From 26b2817a7067cf74d8754579236292fa22752e86 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 13:58:19 +0200 Subject: [PATCH 259/327] refactor loading for abstracted `get_representation_files` --- openpype/hosts/resolve/api/lib.py | 40 ++-------- openpype/hosts/resolve/api/plugin.py | 75 +++++-------------- .../hosts/resolve/plugins/load/load_clip.py | 15 ++-- 3 files changed, 35 insertions(+), 95 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 942caca72a..70a7680d8d 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -190,11 +190,7 @@ def remove_media_pool_item(media_pool_item: object) -> bool: def create_media_pool_item( - fpath: str, - frame_start: int, - frame_end: int, - handle_start: int, - handle_end: int, + files: list, root: object = None, ) -> object: """ @@ -212,49 +208,23 @@ def create_media_pool_item( root_bin = root or media_pool.GetRootFolder() # try to search in bin if the clip does not exist - existing_mpi = get_media_pool_item(fpath, root_bin) + existing_mpi = get_media_pool_item(files[0], root_bin) if existing_mpi: return existing_mpi - files = [] - first_frame = frame_start - handle_start - last_frame = frame_end + handle_end - dir_path = os.path.dirname(fpath) - base_name = os.path.basename(fpath) - - # prepare glob pattern for searching - padding = len(str(last_frame)) - str_first_frame = str(first_frame).zfill(padding) - - # convert str_first_frame to glob pattern - # replace all digits with `?` and all other chars with `[char]` - # example: `0001` -> `????` - glob_pattern = re.sub(r"\d", "?", str_first_frame) - - # in filename replace number with glob pattern - # example: `filename.0001.exr` -> `filename.????.exr` - base_name = re.sub(str_first_frame, glob_pattern, base_name) - - # get all files in folder - for file in glob.glob(os.path.join(dir_path, base_name)): - files.append(file) - - # keep only existing files - files = [f for f in files if os.path.exists(f)] - # add all data in folder to media pool media_pool_items = media_pool.ImportMedia(files) return media_pool_items.pop() if media_pool_items else False -def get_media_pool_item(fpath, root: object = None) -> object: +def get_media_pool_item(filepath, root: object = None) -> object: """ Return clip if found in folder with use of input file path. Args: - fpath (str): absolute path to a file + filepath (str): absolute path to a file root (resolve.Folder)[optional]: root folder / bin object Returns: @@ -262,7 +232,7 @@ def get_media_pool_item(fpath, root: object = None) -> object: """ media_pool = get_current_project().GetMediaPool() root = root or media_pool.GetRootFolder() - fname = os.path.basename(fpath) + fname = os.path.basename(filepath) for _mpi in root.GetClipList(): _mpi_name = _mpi.GetClipProperty("File Name") diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 85245a5d12..b1d6b595c1 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -290,7 +290,7 @@ class ClipLoader: active_bin = None data = dict() - def __init__(self, loader_obj, context, path, **options): + def __init__(self, loader_obj, context, **options): """ Initialize object Arguments: @@ -303,7 +303,6 @@ class ClipLoader: self.__dict__.update(loader_obj.__dict__) self.context = context self.active_project = lib.get_current_project() - self.fname = path # try to get value from options or evaluate key value for `handles` self.with_handles = options.get("handles") or bool( @@ -343,37 +342,29 @@ class ClipLoader: data structure: { "name": "assetName_subsetName_representationName" - "path": "path/to/file/created/by/get_repr..", "binPath": "projectBinPath", } """ # create name - repr = self.context["representation"] - repr_cntx = repr["context"] - asset = str(repr_cntx["asset"]) - subset = str(repr_cntx["subset"]) - representation = str(repr_cntx["representation"]) + representation = self.context["representation"] + representation_context = representation["context"] + asset = str(representation_context["asset"]) + subset = str(representation_context["subset"]) + representation_name = str(representation_context["representation"]) self.data["clip_name"] = "_".join([ asset, subset, - representation + representation_name ]) self.data["versionData"] = self.context["version"]["data"] - # gets file path - file = self.fname - if not file: - repr_id = repr["_id"] - print( - "Representation id `{}` is failing to load".format(repr_id)) - return None - self.data["path"] = file.replace("\\", "/") + self.data["timeline_basename"] = "timeline_{}_{}".format( - subset, representation) + subset, representation_name) # solve project bin structure path hierarchy = str("/".join(( "Loader", - repr_cntx["hierarchy"].replace("\\", "/"), + representation_context["hierarchy"].replace("\\", "/"), asset ))) @@ -390,39 +381,20 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def _get_frame_data(self): - # create mediaItem in active project bin - # create clip media - frame_start = self.data["versionData"].get("frameStart") - frame_end = self.data["versionData"].get("frameEnd") - if frame_start is None: - frame_start = int(self.data["assetData"]["frameStart"]) - if frame_end is None: - frame_end = int(self.data["assetData"]["frameEnd"]) - # get handles - handle_start = self.data["versionData"].get("handleStart") - handle_end = self.data["versionData"].get("handleEnd") - if handle_start is None: - handle_start = int(self.data["assetData"]["handleStart"]) - if handle_end is None: - handle_end = int(self.data["assetData"]["handleEnd"]) + def load(self, files): + """Load clip into timeline - return frame_start, frame_end, handle_start, handle_end - - def load(self): + Arguments: + files (list): list of files to load into timeline + """ # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - - frame_start, frame_end, handle_start, handle_end = \ - self._get_frame_data() + handle_start = self.data["versionData"].get("handleStart", 0) + handle_end = self.data["versionData"].get("handleEnd", 0) media_pool_item = lib.create_media_pool_item( - self.data["path"], - frame_start, - frame_end, - handle_start, - handle_end, + files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty @@ -446,21 +418,14 @@ class ClipLoader: print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item - def update(self, timeline_item): + def update(self, timeline_item, files): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - frame_start, frame_end, handle_start, handle_end = \ - self._get_frame_data() - # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( - self.data["path"], - frame_start, - frame_end, - handle_start, - handle_end, + files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index 5e81441332..35a6b97eea 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -3,6 +3,7 @@ from openpype.pipeline import ( get_representation_path, get_representation_context, get_current_project_name, + get_representation_files ) from openpype.hosts.resolve.api import lib, plugin from openpype.hosts.resolve.api.pipeline import ( @@ -44,9 +45,11 @@ class LoadClip(plugin.TimelineItemLoader): def load(self, context, name, namespace, options): # load clip to timeline and get main variables - path = self.filepath_from_context(context) + filepath = self.filepath_from_context(context) + files = get_representation_files(context, filepath) + timeline_item = plugin.ClipLoader( - self, context, path, **options).load() + self, context, **options).load(files) namespace = namespace or timeline_item.GetName() # update color of clip regarding the version order @@ -73,9 +76,11 @@ class LoadClip(plugin.TimelineItemLoader): media_pool_item = timeline_item.GetMediaPoolItem() - path = get_representation_path(representation) - loader = plugin.ClipLoader(self, context, path) - timeline_item = loader.update(timeline_item) + filepath = get_representation_path(representation) + files = get_representation_files(context, filepath) + + loader = plugin.ClipLoader(self, context) + timeline_item = loader.update(timeline_item, files) # update color of clip regarding the version order self.set_item_color(timeline_item, version=context["version"]) From 1d02f46e1558feeea019178fc46928e3cddde36e Mon Sep 17 00:00:00 2001 From: Sharkitty <81646000+Sharkitty@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:12:26 +0000 Subject: [PATCH 260/327] Feature: Copy resources when downloading last workfile (#4944) * Feature: Copy resources when downloading workfile * Fixed resources dir var name * Removing prints * Fix wrong resources path * Fixed workfile copied to resources folder + lint * Added comments * Handling resource already exists * linting * more linting * Bugfix: copy resources backslash in main path * linting * Using more continue statements, and more comments --------- Co-authored-by: Petr Kalis --- .../pre_copy_last_published_workfile.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index 047e35e3ac..4a8099606b 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -1,5 +1,6 @@ import os import shutil +import filecmp from openpype.client.entities import get_representations from openpype.lib.applications import PreLaunchHook, LaunchTypes @@ -194,3 +195,71 @@ class CopyLastPublishedWorkfile(PreLaunchHook): self.data["last_workfile_path"] = local_workfile_path # Keep source filepath for further path conformation self.data["source_filepath"] = last_published_workfile_path + + # Get resources directory + resources_dir = os.path.join( + os.path.dirname(local_workfile_path), 'resources' + ) + # Make resource directory if it doesn't exist + if not os.path.exists(resources_dir): + os.mkdir(resources_dir) + + # Copy resources to the local resources directory + for file in workfile_representation['files']: + # Get resource main path + resource_main_path = file["path"].replace( + "{root[main]}", str(anatomy.roots["main"]) + ) + + # Only copy if the resource file exists, and it's not the workfile + if ( + not os.path.exists(resource_main_path) + and not resource_main_path != last_published_workfile_path + ): + continue + + # Get resource file basename + resource_basename = os.path.basename(resource_main_path) + + # Get resource path in workfile folder + resource_work_path = os.path.join( + resources_dir, resource_basename + ) + if not os.path.exists(resource_work_path): + continue + + # Check if the resource file already exists + # in the workfile resources folder, + # and both files are the same. + if filecmp.cmp(resource_main_path, resource_work_path): + self.log.warning( + 'Resource "{}" already exists.' + .format(resource_basename) + ) + continue + else: + # Add `.old` to existing resource path + resource_path_old = resource_work_path + '.old' + if os.path.exists(resource_work_path + '.old'): + for i in range(1, 100): + p = resource_path_old + '%02d' % i + if not os.path.exists(p): + # Rename existing resource file to + # `resource_name.old` + 2 digits + shutil.move(resource_work_path, p) + break + else: + self.log.warning( + 'There are a hundred old files for ' + 'resource "{}". ' + 'Perhaps is it time to clean up your ' + 'resources folder' + .format(resource_basename) + ) + continue + else: + # Rename existing resource file to `resource_name.old` + shutil.move(resource_work_path, resource_path_old) + + # Copy resource file to workfile resources folder + shutil.copy(resource_main_path, resources_dir) From 366bfb24354f62896db7f34baba80d28e54d431d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 15:30:37 +0200 Subject: [PATCH 261/327] hound --- openpype/hosts/resolve/api/lib.py | 1 - openpype/hosts/resolve/api/plugin.py | 1 - 2 files changed, 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 70a7680d8d..4066dd34fd 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -2,7 +2,6 @@ import sys import json import re import os -import glob import contextlib from opentimelineio import opentime diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index b1d6b595c1..f3a65034fb 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -381,7 +381,6 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def load(self, files): """Load clip into timeline From 92a256d7571afa6023ab95fdb54dfea38754d9f0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 15:41:03 +0200 Subject: [PATCH 262/327] false docstring --- openpype/pipeline/load/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 81175a8261..5193eaa86e 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -292,7 +292,7 @@ def get_representation_files(context, filepath): """Return list of files for representation. Args: - representation (dict): Representation document. + context (dict): The full loading context. filepath (str): Filepath of the representation. Returns: From 8168c96ae5d8392e05d07e7690bddf82d29c7ac8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 9 Oct 2023 15:44:59 +0200 Subject: [PATCH 263/327] :recycle: some more fixes --- .../plugins/create/create_multishot_layout.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 6ff40851e3..0f40f74be8 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -66,20 +66,22 @@ class CreateMultishotLayout(plugin.MayaCreator): # } # ] + # add the project as the first item + items_with_label = [ + dict(label=f"{self.project_name} " + "(shots directly under the project)", value="") + ] + # go through the current folder path and add each part to the list, # but mark the current folder. - for part_idx in range(len(current_path_parts)): - label = current_path_parts[part_idx] - if current_path_parts[part_idx] == current_folder["name"]: - label = f"{current_path_parts[part_idx]} (current)" - items_with_label.append( - dict(label=label, - value="/".join(current_path_parts[:part_idx + 1])) - ) - # add the project as the first item - items_with_label.insert( - 0, dict(label=f"{self.project_name} " - "(shots directly under the project)", value="")) + for part_idx, part in enumerate(current_path_parts): + label = part + if label == current_folder["name"]: + label = f"{label} (current)" + + value = "/".join(current_path_parts[:part_idx + 1]) + + items_with_label.append({"label": label, "value": value}) return [ EnumDef("shotParent", @@ -115,10 +117,14 @@ class CreateMultishotLayout(plugin.MayaCreator): layout_creator_id = "io.openpype.creators.maya.layout" layout_creator: Creator = self.create_context.creators.get( layout_creator_id) + if not layout_creator: + raise CreatorError( + f"Creator {layout_creator_id} not found.") # Get OpenPype style asset documents for the shots op_asset_docs = get_assets( self.project_name, [s["id"] for s in shots]) + asset_docs_by_id = {doc["_id"]: doc for doc in op_asset_docs} for shot in shots: # we are setting shot name to be displayed in the sequencer to # `shot name (shot label)` if the label is set, otherwise just @@ -128,13 +134,9 @@ class CreateMultishotLayout(plugin.MayaCreator): continue # get task for shot - asset_doc = next( - asset_doc for asset_doc in op_asset_docs - if asset_doc["_id"] == shot["id"] + asset_doc = asset_docs_by_id[shot["id"]] - ) - - tasks = list(asset_doc.get("data").get("tasks").keys()) + tasks = asset_doc.get("data").get("tasks").keys() layout_task = None if pre_create_data["taskName"] in tasks: layout_task = pre_create_data["taskName"] From 77a0930ed04cb567ea673fe28123efb28c370eb5 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 9 Oct 2023 15:51:31 +0200 Subject: [PATCH 264/327] :dog: happy dog --- .../hosts/maya/plugins/create/create_multishot_layout.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 0f40f74be8..eb36825fc4 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -68,8 +68,11 @@ class CreateMultishotLayout(plugin.MayaCreator): # add the project as the first item items_with_label = [ - dict(label=f"{self.project_name} " - "(shots directly under the project)", value="") + { + "label": f"{self.project_name} " + "(shots directly under the project)", + "value": "" + } ] # go through the current folder path and add each part to the list, From ac9f08edf6ae02557b4b3a48d5fbca0388227f81 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 9 Oct 2023 16:05:52 +0200 Subject: [PATCH 265/327] :recycle: use long arguments --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index eb36825fc4..36fee655e6 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -146,8 +146,8 @@ class CreateMultishotLayout(plugin.MayaCreator): shot_name = f"{shot['name']}%s" % ( f" ({shot['label']})" if shot["label"] else "") - cmds.shot(sst=shot["attrib"]["clipIn"], - set=shot["attrib"]["clipOut"], + cmds.shot(sequenceStartTime=shot["attrib"]["clipIn"], + sequenceEndTime=shot["attrib"]["clipOut"], shotName=shot_name) # Create layout instance by the layout creator From 71a1365216fc9f89b150fa3903be99cbf85c9c38 Mon Sep 17 00:00:00 2001 From: Sharkitty <81646000+Sharkitty@users.noreply.github.com> Date: Mon, 9 Oct 2023 15:11:35 +0000 Subject: [PATCH 266/327] Fix: Hardcoded main site and wrongly copied workfile (#5733) --- .../pre_copy_last_published_workfile.py | 82 +++++++++---------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index 4a8099606b..bdb4b109a1 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -207,59 +207,57 @@ class CopyLastPublishedWorkfile(PreLaunchHook): # Copy resources to the local resources directory for file in workfile_representation['files']: # Get resource main path - resource_main_path = file["path"].replace( - "{root[main]}", str(anatomy.roots["main"]) - ) + resource_main_path = anatomy.fill_root(file["path"]) + + # Get resource file basename + resource_basename = os.path.basename(resource_main_path) # Only copy if the resource file exists, and it's not the workfile if ( not os.path.exists(resource_main_path) - and not resource_main_path != last_published_workfile_path + or resource_basename == os.path.basename( + last_published_workfile_path + ) ): continue - # Get resource file basename - resource_basename = os.path.basename(resource_main_path) - # Get resource path in workfile folder resource_work_path = os.path.join( resources_dir, resource_basename ) - if not os.path.exists(resource_work_path): - continue - # Check if the resource file already exists - # in the workfile resources folder, - # and both files are the same. - if filecmp.cmp(resource_main_path, resource_work_path): - self.log.warning( - 'Resource "{}" already exists.' - .format(resource_basename) - ) - continue - else: - # Add `.old` to existing resource path - resource_path_old = resource_work_path + '.old' - if os.path.exists(resource_work_path + '.old'): - for i in range(1, 100): - p = resource_path_old + '%02d' % i - if not os.path.exists(p): - # Rename existing resource file to - # `resource_name.old` + 2 digits - shutil.move(resource_work_path, p) - break - else: - self.log.warning( - 'There are a hundred old files for ' - 'resource "{}". ' - 'Perhaps is it time to clean up your ' - 'resources folder' - .format(resource_basename) - ) - continue + # Check if the resource file already exists in the resources folder + if os.path.exists(resource_work_path): + # Check if both files are the same + if filecmp.cmp(resource_main_path, resource_work_path): + self.log.warning( + 'Resource "{}" already exists.' + .format(resource_basename) + ) + continue else: - # Rename existing resource file to `resource_name.old` - shutil.move(resource_work_path, resource_path_old) + # Add `.old` to existing resource path + resource_path_old = resource_work_path + '.old' + if os.path.exists(resource_work_path + '.old'): + for i in range(1, 100): + p = resource_path_old + '%02d' % i + if not os.path.exists(p): + # Rename existing resource file to + # `resource_name.old` + 2 digits + shutil.move(resource_work_path, p) + break + else: + self.log.warning( + 'There are a hundred old files for ' + 'resource "{}". ' + 'Perhaps is it time to clean up your ' + 'resources folder' + .format(resource_basename) + ) + continue + else: + # Rename existing resource file to `resource_name.old` + shutil.move(resource_work_path, resource_path_old) - # Copy resource file to workfile resources folder - shutil.copy(resource_main_path, resources_dir) + # Copy resource file to workfile resources folder + shutil.copy(resource_main_path, resources_dir) From ca1492c839d91d05d81ec5f9ec063be3552ce8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 9 Oct 2023 17:36:11 +0200 Subject: [PATCH 267/327] General: Avoid fallback if value is 0 for handle start/end (#5652) * Change defaults for handleStart so if it returns 0 it doesn't fallback to the context data * Update get fallbacks for the rest of arguments * Create context variable to shorten lines * Add step to TimeData object --- openpype/pipeline/farm/pyblish_functions.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index fe3ab97de8..7ef3439dbd 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -107,17 +107,18 @@ def get_time_data_from_instance_or_context(instance): TimeData: dataclass holding time information. """ + context = instance.context return TimeData( - start=(instance.data.get("frameStart") or - instance.context.data.get("frameStart")), - end=(instance.data.get("frameEnd") or - instance.context.data.get("frameEnd")), - fps=(instance.data.get("fps") or - instance.context.data.get("fps")), - handle_start=(instance.data.get("handleStart") or - instance.context.data.get("handleStart")), # noqa: E501 - handle_end=(instance.data.get("handleEnd") or - instance.context.data.get("handleEnd")) + start=instance.data.get("frameStart", context.data.get("frameStart")), + end=instance.data.get("frameEnd", context.data.get("frameEnd")), + fps=instance.data.get("fps", context.data.get("fps")), + step=instance.data.get("byFrameStep", instance.data.get("step", 1)), + handle_start=instance.data.get( + "handleStart", context.data.get("handleStart") + ), + handle_end=instance.data.get( + "handleEnd", context.data.get("handleEnd") + ) ) From b59dd55726a8df09a7d91f4ed6ef05179e58a015 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Mon, 9 Oct 2023 17:36:56 +0100 Subject: [PATCH 268/327] Update openpype/settings/defaults/system_settings/applications.json Co-authored-by: Kayla Man <64118225+moonyuet@users.noreply.github.com> --- openpype/settings/defaults/system_settings/applications.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index b100704ffe..2cb75a9515 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -157,7 +157,7 @@ ], "darwin": [], "linux": [ - "/usr/autodesk/maya2024/bin/mayapy" + "/usr/autodesk/maya2023/bin/mayapy" ] }, "arguments": { From f2772d58574c6c6845671f8c3f42f2499a56cad2 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 9 Oct 2023 17:41:35 +0100 Subject: [PATCH 269/327] AYON settings --- .../applications/server/applications.json | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index e40b8d41f6..60305cf1c4 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -109,6 +109,55 @@ } ] }, + "maya": { + "enabled": true, + "label": "Maya", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n", + "variants": [ + { + "name": "2024", + "label": "2024", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}", + "use_python_2": false + }, + { + "name": "2023", + "label": "2023", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2023/bin/mayapy" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2023\"\n}", + "use_python_2": false + } + ] + }, "adsk_3dsmax": { "enabled": true, "label": "3ds Max", From 31ffb5e8260e7c41b0d19c3092ebf7d97d790e18 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 9 Oct 2023 18:26:29 +0100 Subject: [PATCH 270/327] Ingest Maya usersetup --- .../hosts/maya/input/startup/userSetup.py | 26 +++++++++++++++++++ tests/integration/hosts/maya/lib.py | 16 ++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 tests/integration/hosts/maya/input/startup/userSetup.py diff --git a/tests/integration/hosts/maya/input/startup/userSetup.py b/tests/integration/hosts/maya/input/startup/userSetup.py new file mode 100644 index 0000000000..6914b41b1a --- /dev/null +++ b/tests/integration/hosts/maya/input/startup/userSetup.py @@ -0,0 +1,26 @@ +import logging +import sys + +from maya import cmds + + +def setup_pyblish_logging(): + log = logging.getLogger("pyblish") + hnd = logging.StreamHandler(sys.stdout) + fmt = logging.Formatter( + "pyblish (%(levelname)s) (line: %(lineno)d) %(name)s:" + "\n%(message)s" + ) + hnd.setFormatter(fmt) + log.addHandler(hnd) + + +def main(): + cmds.evalDeferred("setup_pyblish_logging()", evaluateNext=True) + cmds.evalDeferred( + "import pyblish.util;pyblish.util.publish()", lowestPriority=True + ) + cmds.evalDeferred("cmds.quit(force=True)", lowestPriority=True) + + +main() diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index e7480e25fa..f27d516605 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -33,16 +33,16 @@ class MayaHostFixtures(HostFixtures): yield dest_path @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session, download_test_data): + def startup_scripts(self, monkeypatch_session): """Points Maya to userSetup file from input data""" - startup_path = os.path.join(download_test_data, - "input", - "startup") + startup_path = os.path.join( + os.path.dirname(__file__), "input", "startup" + ) original_pythonpath = os.environ.get("PYTHONPATH") - monkeypatch_session.setenv("PYTHONPATH", - "{}{}{}".format(startup_path, - os.pathsep, - original_pythonpath)) + monkeypatch_session.setenv( + "PYTHONPATH", + "{}{}{}".format(startup_path, os.pathsep, original_pythonpath) + ) @pytest.fixture(scope="module") def skip_compare_folders(self): From 067aa2ca4d4961e6d00c13c80ea67ed4045ae2b7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Oct 2023 10:49:39 +0200 Subject: [PATCH 271/327] Bugfix: ServerDeleteOperation asset -> folder conversion typo (#5735) * Fix typo * Fix docstring typos --- openpype/client/server/operations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py index eeb55784e1..5b38405c34 100644 --- a/openpype/client/server/operations.py +++ b/openpype/client/server/operations.py @@ -422,7 +422,7 @@ def failed_json_default(value): class ServerCreateOperation(CreateOperation): - """Opeartion to create an entity. + """Operation to create an entity. Args: project_name (str): On which project operation will happen. @@ -634,7 +634,7 @@ class ServerUpdateOperation(UpdateOperation): class ServerDeleteOperation(DeleteOperation): - """Opeartion to delete an entity. + """Operation to delete an entity. Args: project_name (str): On which project operation will happen. @@ -647,7 +647,7 @@ class ServerDeleteOperation(DeleteOperation): self._session = session if entity_type == "asset": - entity_type == "folder" + entity_type = "folder" elif entity_type == "hero_version": entity_type = "version" From bb4134d96a5cc14a74285fb9931f100b89e0ca1f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 13:43:46 +0200 Subject: [PATCH 272/327] fixing variable name to be plural --- openpype/hosts/nuke/plugins/load/actions.py | 2 +- openpype/hosts/nuke/plugins/load/load_backdrop.py | 2 +- openpype/hosts/nuke/plugins/load/load_camera_abc.py | 2 +- openpype/hosts/nuke/plugins/load/load_effects.py | 2 +- openpype/hosts/nuke/plugins/load/load_effects_ip.py | 2 +- openpype/hosts/nuke/plugins/load/load_gizmo.py | 2 +- openpype/hosts/nuke/plugins/load/load_gizmo_ip.py | 2 +- openpype/hosts/nuke/plugins/load/load_matchmove.py | 2 +- openpype/hosts/nuke/plugins/load/load_model.py | 2 +- openpype/hosts/nuke/plugins/load/load_script_precomp.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 3227a7ed98..635318f53d 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): "yeticache", "pointcache"] representations = ["*"] - extension = {"*"} + extensions = {"*"} label = "Set frame range" order = 11 diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index fe82d70b5e..0cbd380697 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Import Nuke Nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 2939ceebae..e245b0cb5e 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin): families = ["camera"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic Camera" icon = "camera" diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 89597e76cc..cacc00854e 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index efe67be4aa..bdf3cd6965 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 6b848ee276..ede05c422b 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -26,7 +26,7 @@ class LoadGizmo(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"gizmo"} label = "Load Gizmo" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index a8e1218cbe..d567aaf7b0 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -28,7 +28,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"gizmo"} label = "Load Gizmo - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py index f942422c00..14ddf20dc3 100644 --- a/openpype/hosts/nuke/plugins/load/load_matchmove.py +++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py @@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin): families = ["matchmove"] representations = ["*"] - extension = {"py"} + extensions = {"py"} defaults = ["Camera", "Object"] diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 0bdcd93dff..b9b8a0f4c0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin): families = ["model", "pointcache", "animation"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic" icon = "cube" diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 48d4a0900a..d5f9d24765 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Load Precomp" order = 0 From b49c04f5706a2d21755d646e02d80a3651a43f9b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 10 Oct 2023 17:56:47 +0100 Subject: [PATCH 273/327] Rely less on deferred execution --- .../hosts/maya/input/startup/userSetup.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/integration/hosts/maya/input/startup/userSetup.py b/tests/integration/hosts/maya/input/startup/userSetup.py index 6914b41b1a..67352af63d 100644 --- a/tests/integration/hosts/maya/input/startup/userSetup.py +++ b/tests/integration/hosts/maya/input/startup/userSetup.py @@ -3,6 +3,8 @@ import sys from maya import cmds +import pyblish.util + def setup_pyblish_logging(): log = logging.getLogger("pyblish") @@ -15,12 +17,12 @@ def setup_pyblish_logging(): log.addHandler(hnd) -def main(): - cmds.evalDeferred("setup_pyblish_logging()", evaluateNext=True) - cmds.evalDeferred( - "import pyblish.util;pyblish.util.publish()", lowestPriority=True - ) - cmds.evalDeferred("cmds.quit(force=True)", lowestPriority=True) +def _run_publish_test_deferred(): + try: + pyblish.util.publish() + finally: + cmds.quit(force=True) -main() +cmds.evalDeferred("setup_pyblish_logging()", evaluateNext=True) +cmds.evalDeferred("_run_publish_test_deferred()", lowestPriority=True) From 32000bd160657a784e9b74b171901d228f80a9ec Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 22:32:22 +0200 Subject: [PATCH 274/327] reverting global abstraction --- openpype/pipeline/__init__.py | 2 -- openpype/pipeline/load/__init__.py | 2 -- openpype/pipeline/load/utils.py | 50 ------------------------------ 3 files changed, 54 deletions(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index ca2a6bcf2c..8f370d389b 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -48,7 +48,6 @@ from .load import ( loaders_from_representation, get_representation_path, get_representation_context, - get_representation_files, get_repres_contexts, ) @@ -153,7 +152,6 @@ __all__ = ( "loaders_from_representation", "get_representation_path", "get_representation_context", - "get_representation_files", "get_repres_contexts", # --- Publish --- diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index c07388fd45..7320a9f0e8 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -11,7 +11,6 @@ from .utils import ( get_contexts_for_repre_docs, get_subset_contexts, get_representation_context, - get_representation_files, load_with_repre_context, load_with_subset_context, @@ -65,7 +64,6 @@ __all__ = ( "get_contexts_for_repre_docs", "get_subset_contexts", "get_representation_context", - "get_representation_files", "load_with_repre_context", "load_with_subset_context", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 5193eaa86e..b10d6032b3 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -1,6 +1,4 @@ import os -import re -import glob import platform import copy import getpass @@ -288,54 +286,6 @@ def get_representation_context(representation): return context -def get_representation_files(context, filepath): - """Return list of files for representation. - - Args: - context (dict): The full loading context. - filepath (str): Filepath of the representation. - - Returns: - list[str]: List of files for representation. - """ - version = context["version"] - frame_start = version["data"]["frameStart"] - frame_end = version["data"]["frameEnd"] - handle_start = version["data"]["handleStart"] - handle_end = version["data"]["handleEnd"] - - first_frame = frame_start - handle_start - last_frame = frame_end + handle_end - dir_path = os.path.dirname(filepath) - base_name = os.path.basename(filepath) - - # prepare glob pattern for searching - padding = len(str(last_frame)) - str_first_frame = str(first_frame).zfill(padding) - - # convert str_first_frame to glob pattern - # replace all digits with `?` and all other chars with `[char]` - # example: `0001` -> `????` - glob_pattern = re.sub(r"\d", "?", str_first_frame) - - # in filename replace number with glob pattern - # example: `filename.0001.exr` -> `filename.????.exr` - base_name = re.sub(str_first_frame, glob_pattern, base_name) - - files = [] - # get all files in folder - for file in glob.glob(os.path.join(dir_path, base_name)): - files.append(file) - - # keep only existing files - files = [f for f in files if os.path.exists(f)] - - # sort files by frame number - files.sort(key=lambda f: int(re.findall(r"\d+", f)[-1])) - - return files - - def load_with_repre_context( Loader, repre_context, namespace=None, name=None, options=None, **kwargs ): From 9145072d514d0ef33edd49a8245d412aa73a6379 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 22:50:11 +0200 Subject: [PATCH 275/327] resolve: get representation files from host api plugin and as suggested here https://github.com/ynput/OpenPype/pull/5673#discussion_r1350315699 --- openpype/hosts/resolve/api/plugin.py | 10 ++++++++++ openpype/hosts/resolve/plugins/load/load_clip.py | 10 +++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index f3a65034fb..a0dba6fd05 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -8,6 +8,7 @@ from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline import ( LegacyCreator, LoaderPlugin, + Anatomy ) from . import lib @@ -825,3 +826,12 @@ class PublishClip: for key in par_split: parent = self._convert_to_entity(key) self.parents.append(parent) + + +def get_representation_files(representation): + anatomy = Anatomy() + files = [] + for file_data in representation["files"]: + path = anatomy.fill_root(file_data["path"]) + files.append(path) + return files diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index 35a6b97eea..d3f83c7f24 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -1,9 +1,7 @@ from openpype.client import get_last_version_by_subset_id from openpype.pipeline import ( - get_representation_path, get_representation_context, - get_current_project_name, - get_representation_files + get_current_project_name ) from openpype.hosts.resolve.api import lib, plugin from openpype.hosts.resolve.api.pipeline import ( @@ -45,8 +43,7 @@ class LoadClip(plugin.TimelineItemLoader): def load(self, context, name, namespace, options): # load clip to timeline and get main variables - filepath = self.filepath_from_context(context) - files = get_representation_files(context, filepath) + files = plugin.get_representation_files(context["representation"]) timeline_item = plugin.ClipLoader( self, context, **options).load(files) @@ -76,8 +73,7 @@ class LoadClip(plugin.TimelineItemLoader): media_pool_item = timeline_item.GetMediaPoolItem() - filepath = get_representation_path(representation) - files = get_representation_files(context, filepath) + files = plugin.get_representation_files(representation) loader = plugin.ClipLoader(self, context) timeline_item = loader.update(timeline_item, files) From a00d456cb1dbf99fc92e3fec5601ec32029f3012 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 11 Oct 2023 03:25:25 +0000 Subject: [PATCH 276/327] [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 01c000e54d..1a316df989 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2-nightly.3" +__version__ = "3.17.2-nightly.4" From d1e1e591f2b0f6fb77cf1d119c9816a705e86723 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Oct 2023 03:26:11 +0000 Subject: [PATCH 277/327] 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 78bea3d838..f74904f79d 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.17.2-nightly.4 - 3.17.2-nightly.3 - 3.17.2-nightly.2 - 3.17.2-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.11-nightly.1 - 3.14.10 - 3.14.10-nightly.9 - - 3.14.10-nightly.8 validations: required: true - type: dropdown From 63828671745691d7463cb49740aaae6846f524b7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Oct 2023 13:29:56 +0200 Subject: [PATCH 278/327] :recycle: move list creation closer to the caller --- .../hosts/maya/plugins/create/create_multishot_layout.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 36fee655e6..c109a76a31 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -185,18 +185,18 @@ class CreateMultishotLayout(plugin.MayaCreator): """ # if folder_path is None, project is selected as a root # and its name is used as a parent id - parent_id = [self.project_name] + parent_id = self.project_name if folder_path: current_folder = get_folder_by_path( project_name=self.project_name, folder_path=folder_path, ) - parent_id = [current_folder["id"]] + parent_id = current_folder["id"] # get all child folders of the current one child_folders = get_folders( project_name=self.project_name, - parent_ids=parent_id, + parent_ids=[parent_id], fields=[ "attrib.clipIn", "attrib.clipOut", "attrib.frameStart", "attrib.frameEnd", From 135f2a9e5741d7623417bd944dbc066af56fd9f4 Mon Sep 17 00:00:00 2001 From: sjt-rvx <72554834+sjt-rvx@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:03:47 +0000 Subject: [PATCH 279/327] do not override the output argument (#5745) --- openpype/settings/ayon_settings.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index d54d71e851..3ccb18111a 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1164,19 +1164,19 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): for profile in extract_oiio_transcode_profiles: new_outputs = {} name_counter = {} - for output in profile["outputs"]: - if "name" in output: - name = output.pop("name") + for profile_output in profile["outputs"]: + if "name" in profile_output: + name = profile_output.pop("name") else: # Backwards compatibility for setting without 'name' in model - name = output["extension"] + name = profile_output["extension"] if name in new_outputs: name_counter[name] += 1 name = "{}_{}".format(name, name_counter[name]) else: name_counter[name] = 0 - new_outputs[name] = output + new_outputs[name] = profile_output profile["outputs"] = new_outputs # Extract Burnin plugin From df431b665c058d74d04f233101b3dfa419fe183b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 11 Oct 2023 17:42:26 +0200 Subject: [PATCH 280/327] Nuke: failing multiple thumbnails integration (#5741) * OP-7031 - fix thumbnail outputName This handles situation when ExtractReviewDataMov has multiple outputs for which are thumbnails created. This would cause an issue in integrate if thumbnail repre should be integrated. * thumbnail name the same as output name - added `delete` tag so it is not integrated - adding output preset name to thumb name if multiple bake streams - adding thumbnails to explicit cleanup paths - thumbnail file name inherited from representation name * hound * comment for py compatibility of unicode * Update openpype/hosts/nuke/plugins/publish/extract_thumbnail.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * thumbnail path key should be `thumbnailPath` * Updates to nuke automatic test Default changed to NOT integrating thumbnail representation. * Update openpype/hosts/nuke/plugins/publish/extract_thumbnail.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * OP-7031 - updated check for thumbnail representation To allow use this plugin as 'name' might not contain only 'thumbnail' for multiple outputs. * Remove possibility of double _ * Implement possibility of multiple thumbnails This could happen if there are multiple output as in Nuke's ExtractREviewMov --------- Co-authored-by: Jakub Jezek Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../nuke/plugins/publish/extract_thumbnail.py | 42 +++++++++++++------ .../preintegrate_thumbnail_representation.py | 28 ++++++------- .../hosts/nuke/test_publish_in_nuke.py | 4 +- 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index b20df4ffe2..46288db743 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -8,6 +8,7 @@ from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings +# Python 2/3 compatibility if sys.version_info[0] >= 3: unicode = str @@ -45,11 +46,12 @@ class ExtractThumbnail(publish.Extractor): for o_name, o_data in instance.data["bakePresets"].items(): self.render_thumbnail(instance, o_name, **o_data) else: - viewer_process_swithes = { + viewer_process_switches = { "bake_viewer_process": True, "bake_viewer_input_process": True } - self.render_thumbnail(instance, None, **viewer_process_swithes) + self.render_thumbnail( + instance, None, **viewer_process_switches) def render_thumbnail(self, instance, output_name=None, **kwargs): first_frame = instance.data["frameStartHandle"] @@ -61,8 +63,6 @@ class ExtractThumbnail(publish.Extractor): # solve output name if any is set output_name = output_name or "" - if output_name: - output_name = "_" + output_name bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ @@ -166,26 +166,42 @@ class ExtractThumbnail(publish.Extractor): previous_node = dag_node temporary_nodes.append(dag_node) + thumb_name = "thumbnail" + # only add output name and + # if there are more than one bake preset + if ( + output_name + and len(instance.data.get("bakePresets", {}).keys()) > 1 + ): + thumb_name = "{}_{}".format(output_name, thumb_name) + # create write node write_node = nuke.createNode("Write") - file = fhead[:-1] + output_name + ".jpg" - name = "thumbnail" - path = os.path.join(staging_dir, file).replace("\\", "/") - instance.data["thumbnail"] = path - write_node["file"].setValue(path) + file = fhead[:-1] + thumb_name + ".jpg" + thumb_path = os.path.join(staging_dir, file).replace("\\", "/") + + # add thumbnail to cleanup + instance.context.data["cleanupFullPaths"].append(thumb_path) + + # make sure only one thumbnail path is set + # and it is existing file + instance_thumb_path = instance.data.get("thumbnailPath") + if not instance_thumb_path or not os.path.isfile(instance_thumb_path): + instance.data["thumbnailPath"] = thumb_path + + write_node["file"].setValue(thumb_path) write_node["file_type"].setValue("jpg") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) - tags = ["thumbnail", "publish_on_farm"] repre = { - 'name': name, + 'name': thumb_name, 'ext': "jpg", - "outputName": "thumb", + "outputName": thumb_name, 'files': file, "stagingDir": staging_dir, - "tags": tags + "tags": ["thumbnail", "publish_on_farm", "delete"] } instance.data["representations"].append(repre) diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py index 1c95b82c97..77bf2edba5 100644 --- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -29,13 +29,12 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not repres: return - thumbnail_repre = None + thumbnail_repres = [] for repre in repres: - if repre["name"] == "thumbnail": - thumbnail_repre = repre - break + if "thumbnail" in repre.get("tags", []): + thumbnail_repres.append(repre) - if not thumbnail_repre: + if not thumbnail_repres: return family = instance.data["family"] @@ -60,14 +59,15 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not found_profile: return - thumbnail_repre.setdefault("tags", []) + for thumbnail_repre in thumbnail_repres: + thumbnail_repre.setdefault("tags", []) - if not found_profile["integrate_thumbnail"]: - if "delete" not in thumbnail_repre["tags"]: - thumbnail_repre["tags"].append("delete") - else: - if "delete" in thumbnail_repre["tags"]: - thumbnail_repre["tags"].remove("delete") + if not found_profile["integrate_thumbnail"]: + if "delete" not in thumbnail_repre["tags"]: + thumbnail_repre["tags"].append("delete") + else: + if "delete" in thumbnail_repre["tags"]: + thumbnail_repre["tags"].remove("delete") - self.log.debug( - "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) + self.log.debug( + "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py index bfd84e4fd5..b7bb8716c0 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -68,7 +68,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): name="workfileTest_task")) failures.append( - DBAssert.count_of_types(dbcon, "representation", 4)) + DBAssert.count_of_types(dbcon, "representation", 3)) additional_args = {"context.subset": "workfileTest_task", "context.ext": "nk"} @@ -85,7 +85,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): additional_args = {"context.subset": "renderTest_taskMain", "name": "thumbnail"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 0, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", From 9bdbc3a8b7f6fb0e1424ad22e47951cd58f61d2a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 17:54:52 +0200 Subject: [PATCH 281/327] refactor validator for asset name to be validator of asset context renaming plugin name and changing functionality to be working with asset key and task key --- ...et_name.xml => validate_asset_context.xml} | 11 +- .../plugins/publish/validate_asset_context.py | 134 +++++++++++++++++ .../plugins/publish/validate_asset_name.py | 138 ------------------ .../publish/validate_output_resolution.py | 2 +- .../defaults/project_settings/nuke.json | 2 +- .../schemas/schema_nuke_publish.json | 2 +- .../nuke/server/settings/publish_plugins.py | 4 +- 7 files changed, 146 insertions(+), 147 deletions(-) rename openpype/hosts/nuke/plugins/publish/help/{validate_asset_name.xml => validate_asset_context.xml} (64%) create mode 100644 openpype/hosts/nuke/plugins/publish/validate_asset_context.py delete mode 100644 openpype/hosts/nuke/plugins/publish/validate_asset_name.py diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml similarity index 64% rename from openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml rename to openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml index 0422917e9c..85efef799a 100644 --- a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml +++ b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml @@ -3,10 +3,13 @@ Shot/Asset name -## Invalid Shot/Asset name in subset +## Invalid node context keys and values -Following Node with name `{node_name}`: -Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`. +Following Node with name: \`{node_name}\` + +Context keys and values: \`{correct_values}\` + +Wrong keys and values: \`{wrong_values}\`. ### How to repair? @@ -15,4 +18,4 @@ Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}` 3. Hit Reload button on the publisher. - \ No newline at end of file + diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py new file mode 100644 index 0000000000..2a7b7a47d5 --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""Validate if instance asset is the same as context asset.""" +from __future__ import absolute_import + +import pyblish.api + +import openpype.hosts.nuke.api.lib as nlib + +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin, + get_errored_instances_from_context +) + + +class SelectInvalidNodesAction(pyblish.api.Action): + + label = "Select Failed Node" + icon = "briefcase" + on = "failed" + + def process(self, context, plugin): + if not hasattr(plugin, "select"): + raise RuntimeError("Plug-in does not have repair method.") + + # Get the failed instances + self.log.debug("Finding failed plug-ins..") + failed_instance = get_errored_instances_from_context(context, plugin) + if failed_instance: + self.log.debug("Attempting selection ...") + plugin.select(failed_instance.pop()) + + +class ValidateCorrectAssetContext( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Validator to check if instance asset context match context asset. + + When working in per-shot style you always publish data in context of + current asset (shot). This validator checks if this is so. It is optional + so it can be disabled when needed. + + Checking `asset` and `task` keys. + + Action on this validator will select invalid instances in Outliner. + """ + order = ValidateContentsOrder + label = "Validate asset context" + hosts = ["nuke"] + actions = [ + RepairAction, + SelectInvalidNodesAction, + ] + optional = True + + # TODO: apply_settigs to maintain backwards compatibility + # with `ValidateCorrectAssetName` + def process(self, instance): + if not self.is_active(instance.data): + return + + invalid_keys = self.get_invalid(instance, compute=True) + + if not invalid_keys: + return + + message_values = { + "node_name": instance.data["transientData"]["node"].name(), + "correct_values": ", ".join([ + "{} > {}".format(_key, instance.context.data[_key]) + for _key in invalid_keys + ]), + "wrong_values": ", ".join([ + "{} > {}".format(_key, instance.data.get(_key)) + for _key in invalid_keys + ]) + } + + msg = ( + "Instance `{node_name}` has wrong context keys:\n" + "Correct: `{correct_values}` | Wrong: `{wrong_values}`").format( + **message_values) + + self.log.debug(msg) + + raise PublishXmlValidationError( + self, msg, formatting_data=message_values + ) + + @classmethod + def get_invalid(cls, instance, compute=False): + invalid = instance.data.get("invalid_keys", []) + + if compute: + testing_keys = ["asset", "task"] + for _key in testing_keys: + if _key not in instance.data: + invalid.append(_key) + continue + if instance.data[_key] != instance.context.data[_key]: + invalid.append(_key) + + instance.data["invalid_keys"] = invalid + + return invalid + + @classmethod + def repair(cls, instance): + invalid = cls.get_invalid(instance) + + create_context = instance.context.data["create_context"] + + instance_id = instance.data.get("instance_id") + created_instance = create_context.get_instance_by_id( + instance_id + ) + for _key in invalid: + created_instance[_key] = instance.context.data[_key] + + create_context.save_changes() + + + @classmethod + def select(cls, instance): + invalid = cls.get_invalid(instance) + if not invalid: + return + + select_node = instance.data["transientData"]["node"] + nlib.reset_selection() + select_node["selected"].setValue(True) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py b/openpype/hosts/nuke/plugins/publish/validate_asset_name.py deleted file mode 100644 index df05f76a5b..0000000000 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate if instance asset is the same as context asset.""" -from __future__ import absolute_import - -import pyblish.api - -import openpype.hosts.nuke.api.lib as nlib - -from openpype.pipeline.publish import ( - ValidateContentsOrder, - PublishXmlValidationError, - OptionalPyblishPluginMixin -) - -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.deselect() - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - for inst in instances: - if inst.data.get("transientData", {}).get("node"): - select_node = inst.data["transientData"]["node"] - select_node["selected"].setValue(True) - - def deselect(self): - nlib.reset_selection() - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - self.log.debug(instances) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - node = instance.data["transientData"]["node"] - node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB) - node_data["asset"] = context_asset - nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data) - - -class ValidateCorrectAssetName( - pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin -): - """Validator to check if instance asset match context asset. - - When working in per-shot style you always publish data in context of - current asset (shot). This validator checks if this is so. It is optional - so it can be disabled when needed. - - Action on this validator will select invalid instances in Outliner. - """ - order = ValidateContentsOrder - label = "Validate correct asset name" - hosts = ["nuke"] - actions = [ - SelectInvalidInstances, - RepairSelectInvalidInstances - ] - optional = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - node = instance.data["transientData"]["node"] - - msg = ( - "Instance `{}` has wrong shot/asset name:\n" - "Correct: `{}` | Wrong: `{}`").format( - instance.name, asset, context_asset) - - self.log.debug(msg) - - if asset != context_asset: - raise PublishXmlValidationError( - self, msg, formatting_data={ - "node_name": node.name(), - "wrong_name": asset, - "correct_name": context_asset - } - ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py index dbcd216a84..39114c80c8 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py +++ b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py @@ -23,7 +23,7 @@ class ValidateOutputResolution( order = pyblish.api.ValidatorOrder optional = True families = ["render"] - label = "Write resolution" + label = "Validate Write resolution" hosts = ["nuke"] actions = [RepairAction] diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ad9f46c8ab..3b69ef54fd 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -341,7 +341,7 @@ "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": true, "optional": true, "active": true 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 fa08e19c63..9e012e560f 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 @@ -61,7 +61,7 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ValidateCorrectAssetName", + "key": "ValidateCorrectAssetContext", "label": "Validate Correct Asset Name" } ] diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 19206149b6..692b2bd240 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -236,7 +236,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=CollectInstanceDataModel, section="Collectors" ) - ValidateCorrectAssetName: OptionalPluginModel = Field( + ValidateCorrectAssetContext: OptionalPluginModel = Field( title="Validate Correct Folder Name", default_factory=OptionalPluginModel, section="Validators" @@ -308,7 +308,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": True, "optional": True, "active": True From 0498a4016d6f0f6dd1a599f9b985daba1805d317 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Oct 2023 18:16:45 +0200 Subject: [PATCH 282/327] Loader tool: Refactor loader tool (for AYON) (#5729) * initial commitof ayon loader * tweaks in ayon utils * implemented product type filtering * products have icons and proper style * fix refresh of products * added enable grouping checkbox * added icons and sorting of grouped items * fix version delegate * add splitter between context and product type filtering * fix products filtering by name * implemented 'filter_repre_contexts_by_loader' * implemented base of action items * implemented folder underline colors * changed version items to dictionary * use 'product_id' instead of 'subset_id' * base implementation of info widget * require less to trigger action * set selection of version ids in controller * added representation widget and related logic changes * implemented actions in representations widget * handle load error * use versions for subset loader * fix representations widget * implemente "in scene" logic properly * use ayon loader in host tools * fix used function to get tasks * show actions per representation name * center window * add window flag to loader window * added 'ThumbnailPainterWidget' to tool utils * implemented thumbnails model * implement thumbnail widget * fix FolderItem args docstring * bypass bug in ayon_api * fix sorting of folders * added refresh button * added expected selection and go to current context * added information if project item is library project * added more filtering options to projects widget * added missing information abou is library to model items * remove select project item on selection change * filter out non library projects * set current context project to project combobox * change window title * fix hero version queries * move current project to the top * fix reset * change icon for library projects * added libraries separator to project widget * show libraries separator in loader * ise single line expression * library loader tool is loader tool in AYON mode * fixes in grouping model * implemented grouping logic * use loader in tray action * better initial sizes * moved 'ActionItem' to abstract * filter loaders by tool name based on current context project * formatting fixes * separate abstract classes into frontend and backend abstractions * added docstrings to abstractions * implemented 'to_data' and 'from_data' for action item options * added more docstrings * first filter representation contexts and then create action items * implemented 'refresh' method * do not reset controller in '_on_first_show' Method '_on_show_timer' will take about the reset. * 'ThumbnailPainterWidget' have more options of bg painting * do not use checkerboard in loader thumbnail * fix condition Co-authored-by: Roy Nieterau --------- Co-authored-by: Roy Nieterau --- openpype/modules/avalon_apps/avalon_app.py | 40 +- openpype/pipeline/load/__init__.py | 2 + openpype/pipeline/load/utils.py | 18 + openpype/pipeline/thumbnail.py | 10 +- openpype/tools/ayon_loader/__init__.py | 6 + openpype/tools/ayon_loader/abstract.py | 851 +++++++++++++++++ openpype/tools/ayon_loader/control.py | 343 +++++++ openpype/tools/ayon_loader/models/__init__.py | 10 + openpype/tools/ayon_loader/models/actions.py | 870 ++++++++++++++++++ openpype/tools/ayon_loader/models/products.py | 682 ++++++++++++++ .../tools/ayon_loader/models/selection.py | 85 ++ openpype/tools/ayon_loader/ui/__init__.py | 6 + .../tools/ayon_loader/ui/actions_utils.py | 118 +++ .../tools/ayon_loader/ui/folders_widget.py | 416 +++++++++ openpype/tools/ayon_loader/ui/info_widget.py | 141 +++ .../ayon_loader/ui/product_group_dialog.py | 45 + .../ayon_loader/ui/product_types_widget.py | 220 +++++ .../ayon_loader/ui/products_delegates.py | 191 ++++ .../tools/ayon_loader/ui/products_model.py | 590 ++++++++++++ .../tools/ayon_loader/ui/products_widget.py | 400 ++++++++ .../tools/ayon_loader/ui/repres_widget.py | 338 +++++++ openpype/tools/ayon_loader/ui/window.py | 511 ++++++++++ openpype/tools/ayon_utils/models/__init__.py | 3 + openpype/tools/ayon_utils/models/hierarchy.py | 94 +- openpype/tools/ayon_utils/models/projects.py | 10 +- .../tools/ayon_utils/models/thumbnails.py | 118 +++ openpype/tools/ayon_utils/widgets/__init__.py | 4 + .../ayon_utils/widgets/folders_widget.py | 27 +- .../ayon_utils/widgets/projects_widget.py | 315 ++++++- .../tools/ayon_utils/widgets/tasks_widget.py | 4 +- openpype/tools/utils/__init__.py | 3 + openpype/tools/utils/host_tools.py | 24 +- openpype/tools/utils/images/__init__.py | 56 ++ openpype/tools/utils/images/thumbnail.png | Bin 0 -> 5118 bytes .../tools/utils/thumbnail_paint_widget.py | 366 ++++++++ 35 files changed, 6843 insertions(+), 74 deletions(-) create mode 100644 openpype/tools/ayon_loader/__init__.py create mode 100644 openpype/tools/ayon_loader/abstract.py create mode 100644 openpype/tools/ayon_loader/control.py create mode 100644 openpype/tools/ayon_loader/models/__init__.py create mode 100644 openpype/tools/ayon_loader/models/actions.py create mode 100644 openpype/tools/ayon_loader/models/products.py create mode 100644 openpype/tools/ayon_loader/models/selection.py create mode 100644 openpype/tools/ayon_loader/ui/__init__.py create mode 100644 openpype/tools/ayon_loader/ui/actions_utils.py create mode 100644 openpype/tools/ayon_loader/ui/folders_widget.py create mode 100644 openpype/tools/ayon_loader/ui/info_widget.py create mode 100644 openpype/tools/ayon_loader/ui/product_group_dialog.py create mode 100644 openpype/tools/ayon_loader/ui/product_types_widget.py create mode 100644 openpype/tools/ayon_loader/ui/products_delegates.py create mode 100644 openpype/tools/ayon_loader/ui/products_model.py create mode 100644 openpype/tools/ayon_loader/ui/products_widget.py create mode 100644 openpype/tools/ayon_loader/ui/repres_widget.py create mode 100644 openpype/tools/ayon_loader/ui/window.py create mode 100644 openpype/tools/ayon_utils/models/thumbnails.py create mode 100644 openpype/tools/utils/images/__init__.py create mode 100644 openpype/tools/utils/images/thumbnail.png create mode 100644 openpype/tools/utils/thumbnail_paint_widget.py diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index a0226ecc5c..57754793c4 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,5 +1,6 @@ import os +from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayModule @@ -75,20 +76,11 @@ class AvalonModule(OpenPypeModule, ITrayModule): def show_library_loader(self): if self._library_loader_window is None: - from qtpy import QtCore - from openpype.tools.libraryloader import LibraryLoaderWindow from openpype.pipeline import install_openpype_plugins - - libraryloader = LibraryLoaderWindow( - show_projects=True, - show_libraries=True - ) - # Remove always on top flag for tray - window_flags = libraryloader.windowFlags() - if window_flags | QtCore.Qt.WindowStaysOnTopHint: - window_flags ^= QtCore.Qt.WindowStaysOnTopHint - libraryloader.setWindowFlags(window_flags) - self._library_loader_window = libraryloader + if AYON_SERVER_ENABLED: + self._init_ayon_loader() + else: + self._init_library_loader() install_openpype_plugins() @@ -106,3 +98,25 @@ class AvalonModule(OpenPypeModule, ITrayModule): if self.tray_initialized: from .rest_api import AvalonRestApiResource self.rest_api_obj = AvalonRestApiResource(self, server_manager) + + def _init_library_loader(self): + from qtpy import QtCore + from openpype.tools.libraryloader import LibraryLoaderWindow + + libraryloader = LibraryLoaderWindow( + show_projects=True, + show_libraries=True + ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self._library_loader_window = libraryloader + + def _init_ayon_loader(self): + from openpype.tools.ayon_loader.ui import LoaderWindow + + libraryloader = LoaderWindow() + + self._library_loader_window = libraryloader diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 7320a9f0e8..ca11b26211 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -32,6 +32,7 @@ from .utils import ( loaders_from_repre_context, loaders_from_representation, + filter_repre_contexts_by_loader, any_outdated_containers, get_outdated_containers, @@ -85,6 +86,7 @@ __all__ = ( "loaders_from_repre_context", "loaders_from_representation", + "filter_repre_contexts_by_loader", "any_outdated_containers", "get_outdated_containers", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index b10d6032b3..c81aeff6bd 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -790,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context): ] +def filter_repre_contexts_by_loader(repre_contexts, loader): + """Filter representation contexts for loader. + + Args: + repre_contexts (list[dict[str, Ant]]): Representation context. + loader (LoaderPlugin): Loader plugin to filter contexts for. + + Returns: + list[dict[str, Any]]: Filtered representation contexts. + """ + + return [ + repre_context + for repre_context in repre_contexts + if is_compatible_loader(loader, repre_context) + ] + + def loaders_from_representation(loaders, representation): """Return all compatible loaders for a representation.""" diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index b2b3679450..63c55d0c19 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -166,8 +166,12 @@ class ServerThumbnailResolver(ThumbnailResolver): # This is new way how thumbnails can be received from server # - output is 'ThumbnailContent' object - if hasattr(ayon_api, "get_thumbnail_by_id"): - result = ayon_api.get_thumbnail_by_id(thumbnail_id) + # NOTE Use 'get_server_api_connection' because public function + # 'get_thumbnail_by_id' does not return output of 'ServerAPI' + # method. + con = ayon_api.get_server_api_connection() + if hasattr(con, "get_thumbnail_by_id"): + result = con.get_thumbnail_by_id(thumbnail_id) if result.is_valid: filepath = cache.store_thumbnail( project_name, @@ -178,7 +182,7 @@ class ServerThumbnailResolver(ThumbnailResolver): else: # Backwards compatibility for ayon api where 'get_thumbnail_by_id' # is not implemented and output is filepath - filepath = ayon_api.get_thumbnail( + filepath = con.get_thumbnail( project_name, entity_type, entity_id, thumbnail_id ) diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py new file mode 100644 index 0000000000..09ecf65f3a --- /dev/null +++ b/openpype/tools/ayon_loader/__init__.py @@ -0,0 +1,6 @@ +from .control import LoaderController + + +__all__ = ( + "LoaderController", +) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py new file mode 100644 index 0000000000..45042395d9 --- /dev/null +++ b/openpype/tools/ayon_loader/abstract.py @@ -0,0 +1,851 @@ +from abc import ABCMeta, abstractmethod +import six + +from openpype.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +class ProductTypeItem: + """Item representing product type. + + Args: + name (str): Product type name. + icon (dict[str, Any]): Product type icon definition. + checked (bool): Is product type checked for filtering. + """ + + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + def to_data(self): + return { + "name": self.name, + "icon": self.icon, + "checked": self.checked, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProductItem: + """Product item with it versions. + + Args: + product_id (str): Product id. + product_type (str): Product type. + product_name (str): Product name. + product_icon (dict[str, Any]): Product icon definition. + product_type_icon (dict[str, Any]): Product type icon definition. + product_in_scene (bool): Is product in scene (only when used in DCC). + group_name (str): Group name. + folder_id (str): Folder id. + folder_label (str): Folder label. + version_items (dict[str, VersionItem]): Version items by id. + """ + + def __init__( + self, + product_id, + product_type, + product_name, + product_icon, + product_type_icon, + product_in_scene, + group_name, + folder_id, + folder_label, + version_items, + ): + self.product_id = product_id + self.product_type = product_type + self.product_name = product_name + self.product_icon = product_icon + self.product_type_icon = product_type_icon + self.product_in_scene = product_in_scene + self.group_name = group_name + self.folder_id = folder_id + self.folder_label = folder_label + self.version_items = version_items + + def to_data(self): + return { + "product_id": self.product_id, + "product_type": self.product_type, + "product_name": self.product_name, + "product_icon": self.product_icon, + "product_type_icon": self.product_type_icon, + "product_in_scene": self.product_in_scene, + "group_name": self.group_name, + "folder_id": self.folder_id, + "folder_label": self.folder_label, + "version_items": { + version_id: version_item.to_data() + for version_id, version_item in self.version_items.items() + }, + } + + @classmethod + def from_data(cls, data): + version_items = { + version_id: VersionItem.from_data(version) + for version_id, version in data["version_items"].items() + } + data["version_items"] = version_items + return cls(**data) + + +class VersionItem: + """Version item. + + Object have implemented comparison operators to be sortable. + + Args: + version_id (str): Version id. + version (int): Version. Can be negative when is hero version. + is_hero (bool): Is hero version. + product_id (str): Product id. + thumbnail_id (Union[str, None]): Thumbnail id. + published_time (Union[str, None]): Published time in format + '%Y%m%dT%H%M%SZ'. + author (Union[str, None]): Author. + frame_range (Union[str, None]): Frame range. + duration (Union[int, None]): Duration. + handles (Union[str, None]): Handles. + step (Union[int, None]): Step. + comment (Union[str, None]): Comment. + source (Union[str, None]): Source. + """ + + def __init__( + self, + version_id, + version, + is_hero, + product_id, + thumbnail_id, + published_time, + author, + frame_range, + duration, + handles, + step, + comment, + source + ): + self.version_id = version_id + self.product_id = product_id + self.thumbnail_id = thumbnail_id + self.version = version + self.is_hero = is_hero + self.published_time = published_time + self.author = author + self.frame_range = frame_range + self.duration = duration + self.handles = handles + self.step = step + self.comment = comment + self.source = source + + def __eq__(self, other): + if not isinstance(other, VersionItem): + return False + return ( + self.is_hero == other.is_hero + and self.version == other.version + and self.version_id == other.version_id + and self.product_id == other.product_id + ) + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + if not isinstance(other, VersionItem): + return False + if ( + other.version == self.version + and self.is_hero + ): + return True + return other.version < self.version + + def to_data(self): + return { + "version_id": self.version_id, + "product_id": self.product_id, + "thumbnail_id": self.thumbnail_id, + "version": self.version, + "is_hero": self.is_hero, + "published_time": self.published_time, + "author": self.author, + "frame_range": self.frame_range, + "duration": self.duration, + "handles": self.handles, + "step": self.step, + "comment": self.comment, + "source": self.source, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class RepreItem: + """Representation item. + + Args: + representation_id (str): Representation id. + representation_name (str): Representation name. + representation_icon (dict[str, Any]): Representation icon definition. + product_name (str): Product name. + folder_label (str): Folder label. + """ + + def __init__( + self, + representation_id, + representation_name, + representation_icon, + product_name, + folder_label, + ): + self.representation_id = representation_id + self.representation_name = representation_name + self.representation_icon = representation_icon + self.product_name = product_name + self.folder_label = folder_label + + def to_data(self): + return { + "representation_id": self.representation_id, + "representation_name": self.representation_name, + "representation_icon": self.representation_icon, + "product_name": self.product_name, + "folder_label": self.folder_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ActionItem: + """Action item that can be triggered. + + Action item is defined for a specific context. To trigger the action + use 'identifier' and context, it necessary also use 'options'. + + Args: + identifier (str): Action identifier. + label (str): Action label. + icon (dict[str, Any]): Action icon definition. + tooltip (str): Action tooltip. + options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): + Action options. Note: 'qargparse' is considered as deprecated. + order (int): Action order. + project_name (str): Project name. + folder_ids (list[str]): Folder ids. + product_ids (list[str]): Product ids. + version_ids (list[str]): Version ids. + representation_ids (list[str]): Representation ids. + """ + + def __init__( + self, + identifier, + label, + icon, + tooltip, + options, + order, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self.identifier = identifier + self.label = label + self.icon = icon + self.tooltip = tooltip + self.options = options + self.order = order + self.project_name = project_name + self.folder_ids = folder_ids + self.product_ids = product_ids + self.version_ids = version_ids + self.representation_ids = representation_ids + + def _options_to_data(self): + options = self.options + if not options: + return options + if isinstance(options[0], AbstractAttrDef): + return serialize_attr_defs(options) + # NOTE: Data conversion is not used by default in loader tool. But for + # future development of detached UI tools it would be better to be + # prepared for it. + raise NotImplementedError( + "{}.to_data is not implemented. Use Attribute definitions" + " from 'openpype.lib' instead of 'qargparse'.".format( + self.__class__.__name__ + ) + ) + + def to_data(self): + options = self._options_to_data() + return { + "identifier": self.identifier, + "label": self.label, + "icon": self.icon, + "tooltip": self.tooltip, + "options": options, + "order": self.order, + "project_name": self.project_name, + "folder_ids": self.folder_ids, + "product_ids": self.product_ids, + "version_ids": self.version_ids, + "representation_ids": self.representation_ids, + } + + @classmethod + def from_data(cls, data): + options = data["options"] + if options: + options = deserialize_attr_defs(options) + data["options"] = options + return cls(**data) + + +@six.add_metaclass(ABCMeta) +class _BaseLoaderController(object): + """Base loader controller abstraction. + + Abstract base class that is required for both frontend and backed. + """ + + @abstractmethod + def get_current_context(self): + """Current context is a context of the current scene. + + Example output: + { + "project_name": "MyProject", + "folder_id": "0011223344-5566778-99", + "task_name": "Compositing", + } + + Returns: + dict[str, Union[str, None]]: Context data. + """ + + pass + + @abstractmethod + def reset(self): + """Reset all cached data to reload everything. + + Triggers events "controller.reset.started" and + "controller.reset.finished". + """ + + pass + + # Model wrappers + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items for a project. + + Args: + project_name (str): Project name. + sender (Optional[str]): Sender who requested the name. + + Returns: + list[FolderItem]: Folder items for the project. + """ + + pass + + # Expected selection helpers + @abstractmethod + def get_expected_selection_data(self): + """Full expected selection information. + + Expected selection is a selection that may not be yet selected in UI + e.g. because of refreshing, this data tell the UI what should be + selected when they finish their refresh. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def set_expected_selection(self, project_name, folder_id): + """Set expected selection. + + Args: + project_name (str): Name of project to be selected. + folder_id (str): Id of folder to be selected. + """ + + pass + + +class BackendLoaderController(_BaseLoaderController): + """Backend loader controller abstraction. + + What backend logic requires from a controller for proper logic. + """ + + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event with a certain topic, data and source. + + The event should be sent to both frontend and backend. + + Args: + topic (str): Event topic name. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_loaded_product_ids(self): + """Return set of loaded product ids. + + Returns: + set[str]: Set of loaded product ids. + """ + + pass + + +class FrontendLoaderController(_BaseLoaderController): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register callback for an event topic. + + Args: + topic (str): Event topic name. + callback (func): Callback triggered when the event is emitted. + """ + + pass + + # Expected selection helpers + @abstractmethod + def expected_project_selected(self, project_name): + """Expected project was selected in frontend. + + Args: + project_name (str): Project name. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in frontend. + + Args: + folder_id (str): Folder id. + """ + + pass + + # Model wrapper calls + @abstractmethod + def get_project_items(self, sender=None): + """Items for all projects available on server. + + Triggers event topics "projects.refresh.started" and + "projects.refresh.finished" with data: + { + "sender": sender + } + + Notes: + Filtering of projects is done in UI. + + Args: + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProjectItem]: List of project items. + """ + + pass + + @abstractmethod + def get_product_items(self, project_name, folder_ids, sender=None): + """Product items for folder ids. + + Triggers event topics "products.refresh.started" and + "products.refresh.finished" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProductItem]: List of product items. + """ + + pass + + @abstractmethod + def get_product_item(self, project_name, product_id): + """Receive single product item. + + Args: + project_name (str): Project name. + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product info or None if not found. + """ + + pass + + @abstractmethod + def get_product_type_items(self, project_name): + """Product type items for a project. + + Product types have defined if are checked for filtering or not. + + Returns: + list[ProductTypeItem]: List of product type items for a project. + """ + + pass + + @abstractmethod + def get_representation_items( + self, project_name, version_ids, sender=None + ): + """Representation items for version ids. + + Triggers event topics "model.representations.refresh.started" and + "model.representations.refresh.finished" with data: + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[RepreItem]: List of representation items. + """ + + pass + + @abstractmethod + def get_version_thumbnail_ids(self, project_name, version_ids): + """Get thumbnail ids for version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by version id. + """ + + pass + + @abstractmethod + def get_folder_thumbnail_ids(self, project_name, folder_ids): + """Get thumbnail ids for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by folder id. + """ + + pass + + @abstractmethod + def get_thumbnail_path(self, project_name, thumbnail_id): + """Get thumbnail path for thumbnail id. + + This method should get a path to a thumbnail based on thumbnail id. + Which probably means to download the thumbnail from server and store + it locally. + + Args: + project_name (str): Project name. + thumbnail_id (str): Thumbnail id. + + Returns: + Union[str, None]: Thumbnail path or None if not found. + """ + + pass + + # Selection model wrapper calls + @abstractmethod + def get_selected_project_name(self): + """Get selected project name. + + The information is based on last selection from UI. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_ids(self): + """Get selected folder ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + """ + + pass + + @abstractmethod + def get_selected_version_ids(self): + """Get selected version ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected version ids. + """ + + pass + + @abstractmethod + def get_selected_representation_ids(self): + """Get selected representation ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected representation ids. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Set selected project. + + Project selection changed in UI. Method triggers event with topic + "selection.project.changed" with data: + { + "project_name": self._project_name + } + + Args: + project_name (Union[str, None]): Selected project name. + """ + + pass + + @abstractmethod + def set_selected_folders(self, folder_ids): + """Set selected folders. + + Folder selection changed in UI. Method triggers event with topic + "selection.folders.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids + } + + Args: + folder_ids (Iterable[str]): Selected folder ids. + """ + + pass + + @abstractmethod + def set_selected_versions(self, version_ids): + """Set selected versions. + + Version selection changed in UI. Method triggers event with topic + "selection.versions.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids + } + + Args: + version_ids (Iterable[str]): Selected version ids. + """ + + pass + + @abstractmethod + def set_selected_representations(self, repre_ids): + """Set selected representations. + + Representation selection changed in UI. Method triggers event with + topic "selection.representations.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids, + "representation_ids": representation_ids + } + + Args: + repre_ids (Iterable[str]): Selected representation ids. + """ + + pass + + # Load action items + @abstractmethod + def get_versions_action_items(self, project_name, version_ids): + """Action items for versions selection. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def get_representations_action_items( + self, project_name, representation_ids + ): + """Action items for representations selection. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action item. + + Triggers event "load.started" with data: + { + "identifier": identifier, + "id": , + } + + And triggers "load.finished" with data: + { + "identifier": identifier, + "id": , + "error_info": [...], + } + + Args: + identifier (str): Action identifier. + options (dict[str, Any]): Action option values from UI. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + pass + + @abstractmethod + def change_products_group(self, project_name, product_ids, group_name): + """Change group of products. + + Triggers event "products.group.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids. + group_name (str): New group name. + """ + + pass + + @abstractmethod + def fill_root_in_source(self, source): + """Fill root in source path. + + Args: + source (Union[str, None]): Source of a published version. Usually + rootless workfile path. + """ + + pass + + # NOTE: Methods 'is_loaded_products_supported' and + # 'is_standard_projects_filter_enabled' are both based on being in host + # or not. Maybe we could implement only single method 'is_in_host'? + @abstractmethod + def is_loaded_products_supported(self): + """Is capable to get information about loaded products. + + Returns: + bool: True if it is supported. + """ + + pass + + @abstractmethod + def is_standard_projects_filter_enabled(self): + """Is standard projects filter enabled. + + This is used for filtering out when loader tool is used in a host. In + that case only current project and library projects should be shown. + + Returns: + bool: Frontend should filter out non-library projects, except + current context project. + """ + + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py new file mode 100644 index 0000000000..2b779f5c2e --- /dev/null +++ b/openpype/tools/ayon_loader/control.py @@ -0,0 +1,343 @@ +import logging + +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.pipeline import Anatomy, get_current_context +from openpype.host import ILoadHost +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, + NestedCacheItem, + CacheItem, + ThumbnailsModel, +) + +from .abstract import BackendLoaderController, FrontendLoaderController +from .models import SelectionModel, ProductsModel, LoaderActionsModel + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class LoaderController(BackendLoaderController, FrontendLoaderController): + """ + + Args: + host (Optional[AbstractHost]): Host object. Defaults to None. + """ + + def __init__(self, host=None): + self._log = None + self._host = host + + self._event_system = self._create_event_system() + + self._project_anatomy_cache = NestedCacheItem( + levels=1, lifetime=60) + self._loaded_products_cache = CacheItem( + default_factory=set, lifetime=60) + + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._products_model = ProductsModel(self) + self._loader_actions_model = LoaderActionsModel(self) + self._thumbnails_model = ThumbnailsModel() + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._emit_event("controller.reset.started") + + project_name = self.get_selected_project_name() + folder_ids = self.get_selected_folder_ids() + + self._project_anatomy_cache.reset() + self._loaded_products_cache.reset() + + self._products_model.reset() + self._hierarchy_model.reset() + self._loader_actions_model.reset() + self._projects_model.reset() + self._thumbnails_model.reset() + + self._projects_model.refresh() + + if not project_name and not folder_ids: + context = self.get_current_context() + project_name = context["project_name"] + folder_id = context["folder_id"] + self.set_expected_selection(project_name, folder_id) + + self._emit_event("controller.reset.finished") + + # Expected selection helpers + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def set_expected_selection(self, project_name, folder_id): + self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Entity model wrappers + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_product_items(self, project_name, folder_ids, sender=None): + return self._products_model.get_product_items( + project_name, folder_ids, sender) + + def get_product_item(self, project_name, product_id): + return self._products_model.get_product_item( + project_name, product_id + ) + + def get_product_type_items(self, project_name): + return self._products_model.get_product_type_items(project_name) + + def get_representation_items( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_repre_items( + project_name, version_ids, sender + ) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + return self._thumbnails_model.get_folder_thumbnail_ids( + project_name, folder_ids) + + def get_version_thumbnail_ids(self, project_name, version_ids): + return self._thumbnails_model.get_version_thumbnail_ids( + project_name, version_ids) + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._thumbnails_model.get_thumbnail_path( + project_name, thumbnail_id + ) + + def change_products_group(self, project_name, product_ids, group_name): + self._products_model.change_products_group( + project_name, product_ids, group_name + ) + + def get_versions_action_items(self, project_name, version_ids): + return self._loader_actions_model.get_versions_action_items( + project_name, version_ids) + + def get_representations_action_items( + self, project_name, representation_ids): + return self._loader_actions_model.get_representations_action_items( + project_name, representation_ids) + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + self._loader_actions_model.trigger_action_item( + identifier, + options, + project_name, + version_ids, + representation_ids + ) + + # Selection model wrappers + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + # Selection model wrappers + def get_selected_folder_ids(self): + return self._selection_model.get_selected_folder_ids() + + def set_selected_folders(self, folder_ids): + self._selection_model.set_selected_folders(folder_ids) + + def get_selected_version_ids(self): + return self._selection_model.get_selected_version_ids() + + def set_selected_versions(self, version_ids): + self._selection_model.set_selected_versions(version_ids) + + def get_selected_representation_ids(self): + return self._selection_model.get_selected_representation_ids() + + def set_selected_representations(self, repre_ids): + self._selection_model.set_selected_representations(repre_ids) + + def fill_root_in_source(self, source): + project_name = self.get_selected_project_name() + anatomy = self._get_project_anatomy(project_name) + if anatomy is None: + return source + + try: + return anatomy.fill_root(source) + except Exception: + return source + + def get_current_context(self): + if self._host is None: + return { + "project_name": None, + "folder_id": None, + "task_name": None, + } + if hasattr(self._host, "get_current_context"): + context = self._host.get_current_context() + else: + context = get_current_context() + folder_id = None + project_name = context.get("project_name") + asset_name = context.get("asset_name") + if project_name and asset_name: + folder = ayon_api.get_folder_by_name( + project_name, asset_name, fields=["id"] + ) + if folder: + folder_id = folder["id"] + return { + "project_name": project_name, + "folder_id": folder_id, + "task_name": context.get("task_name"), + } + + def get_loaded_product_ids(self): + if self._host is None: + return set() + + context = self.get_current_context() + project_name = context["project_name"] + if not project_name: + return set() + + if not self._loaded_products_cache.is_valid: + if isinstance(self._host, ILoadHost): + containers = self._host.get_containers() + else: + containers = self._host.ls() + repre_ids = {c.get("representation") for c in containers} + repre_ids.discard(None) + product_ids = self._products_model.get_product_ids_by_repre_ids( + project_name, repre_ids + ) + self._loaded_products_cache.update_data(product_ids) + return self._loaded_products_cache.get_data() + + def is_loaded_products_supported(self): + return self._host is not None + + def is_standard_projects_filter_enabled(self): + return self._host is not None + + def _get_project_anatomy(self, project_name): + if not project_name: + return None + cache = self._project_anatomy_cache[project_name] + if not cache.is_valid: + cache.update_data(Anatomy(project_name)) + return cache.get_data() + + def _create_event_system(self): + return QueuedEventSystem() + + def _emit_event(self, topic, data=None): + self._event_system.emit(topic, data or {}, "controller") diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py new file mode 100644 index 0000000000..6adfe71d86 --- /dev/null +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -0,0 +1,10 @@ +from .selection import SelectionModel +from .products import ProductsModel +from .actions import LoaderActionsModel + + +__all__ = ( + "SelectionModel", + "ProductsModel", + "LoaderActionsModel", +) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py new file mode 100644 index 0000000000..3edb04e9eb --- /dev/null +++ b/openpype/tools/ayon_loader/models/actions.py @@ -0,0 +1,870 @@ +import sys +import traceback +import inspect +import copy +import collections +import uuid + +from openpype.client import ( + get_project, + get_assets, + get_subsets, + get_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + SubsetLoaderPlugin, + filter_repre_contexts_by_loader, + get_loader_identifier, + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + LoadError, + IncompatibleLoaderError, +) +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem + +ACTIONS_MODEL_SENDER = "actions.model" +NOT_SET = object() + + +class LoaderActionsModel: + """Model for loader actions. + + This is probably only part of models that requires to use codebase from + 'openpype.client' because of backwards compatibility with loaders logic + which are expecting mongo documents. + + TODOs: + Deprecate 'qargparse' usage in loaders and implement conversion + of 'ActionItem' to data (and 'from_data'). + Use controller to get entities (documents) -> possible only when + loaders are able to handle AYON vs. OpenPype logic. + Add missing site sync logic, and if possible remove it from loaders. + Implement loader actions to replace load plugins. + Ask loader actions to return action items instead of guessing them. + """ + + # Cache loader plugins for some time + # NOTE Set to '0' for development + loaders_cache_lifetime = 30 + + def __init__(self, controller): + self._controller = controller + self._current_context_project = NOT_SET + self._loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._product_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._repre_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + + def reset(self): + """Reset the model with all cached items.""" + + self._current_context_project = NOT_SET + self._loaders_by_identifier.reset() + self._product_loaders.reset() + self._repre_loaders.reset() + + def get_versions_action_items(self, project_name, version_ids): + """Get action items for given version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions( + project_name, + version_ids + ) + return self._get_action_items_for_contexts( + project_name, + version_context_by_id, + repre_context_by_id + ) + + def get_representations_action_items( + self, project_name, representation_ids + ): + """Get action items for given representation ids. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + product_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations( + project_name, + representation_ids + ) + return self._get_action_items_for_contexts( + project_name, + product_context_by_id, + repre_context_by_id + ) + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action by identifier. + + Triggers the action by identifier for given contexts. + + Triggers events "load.started" and "load.finished". Finished event + also contains "error_info" key with error information if any + happened. + + Args: + identifier (str): Loader identifier. + options (dict[str, Any]): Loader option values. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + event_data = { + "identifier": identifier, + "id": uuid.uuid4().hex, + } + self._controller.emit_event( + "load.started", + event_data, + ACTIONS_MODEL_SENDER, + ) + loader = self._get_loader_by_identifier(project_name, identifier) + if representation_ids is not None: + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + representation_ids, + ) + elif version_ids is not None: + error_info = self._trigger_version_loader( + loader, + options, + project_name, + version_ids, + ) + else: + raise NotImplementedError( + "Invalid arguments to trigger action item") + + event_data["error_info"] = error_info + self._controller.emit_event( + "load.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + + def _get_current_context_project(self): + """Get current context project name. + + The value is based on controller (host) and cached. + + Returns: + Union[str, None]: Current context project. + """ + + if self._current_context_project is NOT_SET: + context = self._controller.get_current_context() + self._current_context_project = context["project_name"] + return self._current_context_project + + def _get_action_label(self, loader, representation=None): + """Pull label info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + representation (Optional[dict[str, Any]]): Representation data. + + Returns: + str: Action label. + """ + + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + if representation: + # Add the representation as suffix + label = "{} ({})".format(label, representation["name"]) + return label + + def _get_action_icon(self, loader): + """Pull icon info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + Union[dict[str, Any], None]: Icon definition based on + loader plugin. + """ + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None and not isinstance(icon, dict): + icon = { + "type": "awesome-font", + "name": icon, + "color": getattr(loader, "color", None) or "white" + } + return icon + + def _get_action_tooltip(self, loader): + """Pull tooltip info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + str: Action tooltip. + """ + + # Add tooltip and statustip from Loader docstring + return inspect.getdoc(loader) + + def _filter_loaders_by_tool_name(self, project_name, loaders): + """Filter loaders by tool name. + + Tool names are based on OpenPype tools loader tool and library + loader tool. The new tool merged both into one tool and the difference + is based only on current project name. + + Args: + project_name (str): Project name. + loaders (list[LoaderPlugin]): List of loader plugins. + + Returns: + list[LoaderPlugin]: Filtered list of loader plugins. + """ + + # Keep filtering by tool name + # - if current context project name is same as project name we do + # expect the tool is used as OpenPype loader tool, otherwise + # as library loader tool. + if project_name == self._get_current_context_project(): + tool_name = "loader" + else: + tool_name = "library_loader" + filtered_loaders = [] + for loader in loaders: + tool_names = getattr(loader, "tool_names", None) + if ( + tool_names is None + or "*" in tool_names + or tool_name in tool_names + ): + filtered_loaders.append(loader) + return filtered_loaders + + def _create_loader_action_item( + self, + loader, + contexts, + project_name, + folder_ids=None, + product_ids=None, + version_ids=None, + representation_ids=None, + repre_name=None, + ): + label = self._get_action_label(loader) + if repre_name: + label = "{} ({})".format(label, repre_name) + return ActionItem( + get_loader_identifier(loader), + label=label, + icon=self._get_action_icon(loader), + tooltip=self._get_action_tooltip(loader), + options=loader.get_options(contexts), + order=loader.order, + project_name=project_name, + folder_ids=folder_ids, + product_ids=product_ids, + version_ids=version_ids, + representation_ids=representation_ids, + ) + + def _get_loaders(self, project_name): + """Loaders with loaded settings for a project. + + Questions: + Project name is required because of settings. Should we actually + pass in current project name instead of project name where + we want to show loaders for? + + Returns: + tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered + loader plugins. + """ + + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + product_loaders_c = self._product_loaders[project_name] + repre_loaders_c = self._repre_loaders[project_name] + if loaders_by_identifier_c.is_valid: + return product_loaders_c.get_data(), repre_loaders_c.get_data() + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = self._filter_loaders_by_tool_name( + project_name, discover_loader_plugins(project_name) + ) + + repre_loaders = [] + product_loaders = [] + loaders_by_identifier = {} + for loader_cls in available_loaders: + if not loader_cls.enabled: + continue + + identifier = get_loader_identifier(loader_cls) + loaders_by_identifier[identifier] = loader_cls + if issubclass(loader_cls, SubsetLoaderPlugin): + product_loaders.append(loader_cls) + else: + repre_loaders.append(loader_cls) + + loaders_by_identifier_c.update_data(loaders_by_identifier) + product_loaders_c.update_data(product_loaders) + repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders + + def _get_loader_by_identifier(self, project_name, identifier): + if not self._loaders_by_identifier[project_name].is_valid: + self._get_loaders(project_name) + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + loaders_by_identifier = loaders_by_identifier_c.get_data() + return loaders_by_identifier.get(identifier) + + def _actions_sorter(self, action_item): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + """ + + return action_item.order, action_item.label + + def _get_version_docs(self, project_name, version_ids): + """Get version documents for given version ids. + + This function also handles hero versions and copies data from + source version to it. + + Todos: + Remove this function when this is completely rewritten to + use AYON calls. + """ + + version_docs = list(get_versions( + project_name, version_ids=version_ids, hero=True + )) + hero_versions_by_src_id = collections.defaultdict(list) + src_hero_version = set() + for version_doc in version_docs: + if version_doc["type"] != "hero": + continue + version_id = "" + src_hero_version.add(version_id) + hero_versions_by_src_id[version_id].append(version_doc) + + src_versions = [] + if src_hero_version: + src_versions = get_versions(project_name, version_ids=version_ids) + for src_version in src_versions: + src_version_id = src_version["_id"] + for hero_version in hero_versions_by_src_id[src_version_id]: + hero_version["data"] = copy.deepcopy(src_version["data"]) + + return version_docs + + def _contexts_for_versions(self, project_name, version_ids): + """Get contexts for given version ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_representations' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + # TODO fix hero version + version_context_by_id = {} + repre_context_by_id = {} + if not project_name and not version_ids: + return version_context_by_id, repre_context_by_id + + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {} + version_docs_by_product_id = collections.defaultdict(list) + for version_doc in version_docs: + version_id = version_doc["_id"] + product_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_product_id[product_id].append(version_doc) + + _product_ids = set(version_docs_by_product_id.keys()) + _product_docs = get_subsets(project_name, subset_ids=_product_ids) + product_docs_by_id = {p["_id"]: p for p in _product_docs} + + _folder_ids = {p["parent"] for p in product_docs_by_id.values()} + _folder_docs = get_assets(project_name, asset_ids=_folder_ids) + folder_docs_by_id = {f["_id"]: f for f in _folder_docs} + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + version_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + } + + repre_docs = get_representations( + project_name, version_ids=version_ids) + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + + return version_context_by_id, repre_context_by_id + + def _contexts_for_representations(self, project_name, repre_ids): + """Get contexts for given representation ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_versions' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + repre_ids (Iterable[str]): Representation ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + product_context_by_id = {} + repre_context_by_id = {} + if not project_name and not repre_ids: + return product_context_by_id, repre_context_by_id + + repre_docs = list(get_representations( + project_name, representation_ids=repre_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = { + v["_id"]: v for v in version_docs + } + + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = { + p["_id"]: p for p in product_docs + } + + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = { + f["_id"]: f for f in folder_docs + } + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for product_id, product_doc in product_docs_by_id.items(): + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + } + + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + return product_context_by_id, repre_context_by_id + + def _get_action_items_for_contexts( + self, + project_name, + version_context_by_id, + repre_context_by_id + ): + """Prepare action items based on contexts. + + Actions are prepared based on discovered loader plugins and contexts. + The context must be valid for the loader plugin. + + Args: + project_name (str): Project name. + version_context_by_id (dict[str, dict[str, Any]]): Version + contexts by version id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + """ + + action_items = [] + if not version_context_by_id and not repre_context_by_id: + return action_items + + product_loaders, repre_loaders = self._get_loaders(project_name) + + repre_contexts_by_name = collections.defaultdict(list) + for repre_context in repre_context_by_id.values(): + repre_name = repre_context["representation"]["name"] + repre_contexts_by_name[repre_name].append(repre_context) + + for loader in repre_loaders: + # # do not allow download whole repre, select specific repre + # if tools_lib.is_sync_loader(loader): + # continue + + for repre_name, repre_contexts in repre_contexts_by_name.items(): + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if not filtered_repre_contexts: + continue + + repre_ids = set() + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in filtered_repre_contexts: + repre_ids.add(repre_context["representation"]["_id"]) + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids, + repre_name=repre_name, + ) + action_items.append(item) + + # Subset Loaders. + version_ids = set(version_context_by_id.keys()) + product_folder_ids = set() + product_ids = set() + for product_context in version_context_by_id.values(): + product_ids.add(product_context["subset"]["_id"]) + product_folder_ids.add(product_context["asset"]["_id"]) + + version_contexts = list(version_context_by_id.values()) + for loader in product_loaders: + item = self._create_loader_action_item( + loader, + version_contexts, + project_name=project_name, + folder_ids=product_folder_ids, + product_ids=product_ids, + version_ids=version_ids, + ) + action_items.append(item) + + action_items.sort(key=self._actions_sorter) + return action_items + + def _trigger_version_loader( + self, + loader, + options, + project_name, + version_ids, + ): + """Trigger version loader. + + This triggers 'load' method of 'SubsetLoaderPlugin' for given version + ids. + + Note: + Even when the plugin is 'SubsetLoaderPlugin' it actually expects + versions and should be named 'VersionLoaderPlugin'. Because it + is planned to refactor load system and introduce + 'LoaderAction' plugins it is not relevant to change it + anymore. + + Args: + loader (SubsetLoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + version_docs = self._get_version_docs(project_name, version_ids) + product_ids = {v["parent"] for v in version_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {f["_id"]: f for f in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + product_contexts = [] + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + }) + + return self._load_products_by_loader( + loader, product_contexts, options + ) + + def _trigger_representation_loader( + self, + loader, + options, + project_name, + representation_ids, + ): + """Trigger representation loader. + + This triggers 'load' method of 'LoaderPlugin' for given representation + ids. For that are prepared contexts for each representation, with + all parent documents. + + Args: + loader (LoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {v["_id"]: v for v in version_docs} + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {p["_id"]: p for p in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + repre_contexts = [] + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + repre_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + }) + + return self._load_representations_by_loader( + loader, repre_contexts, options + ) + + def _load_representations_by_loader(self, loader, repre_contexts, options): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (LoaderPlugin): Loader plugin to use. + repre_contexts (list[dict]): Full info about selected + representations, containing repre, version, subset, asset and + project documents. + options (dict): Data from options. + """ + + error_info = [] + for repre_context in repre_contexts: + version_doc = repre_context["version"] + if version_doc["type"] == "hero_version": + version_name = "Hero" + else: + version_name = version_doc.get("name") + try: + load_with_repre_context( + loader, + repre_context, + options=options + ) + + except IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + + error_info.append(( + str(exc), + formatted_traceback, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + return error_info + + def _load_products_by_loader(self, loader, version_contexts, options): + """Triggers load with SubsetLoader type of loaders. + + Warning: + Plugin is named 'SubsetLoader' but version is passed to context + too. + + Args: + loader (SubsetLoder): Loader used to load. + version_contexts (list[dict[str, Any]]): For context for each + version. + options (dict[str, Any]): Options for loader that user could fill. + """ + + error_info = [] + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in version_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + try: + load_with_subset_contexts( + loader, + version_contexts, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( + str(exc), + formatted_traceback, + None, + ", ".join(subset_names), + None + )) + else: + for version_context in version_contexts: + subset_name = ( + version_context.get("subset", {}).get("name") or "N/A" + ) + try: + load_with_subset_context( + loader, + version_context, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + + error_info.append(( + str(exc), + formatted_traceback, + None, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py new file mode 100644 index 0000000000..33023cc164 --- /dev/null +++ b/openpype/tools/ayon_loader/models/products.py @@ -0,0 +1,682 @@ +import collections +import contextlib + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ( + ProductTypeItem, + ProductItem, + VersionItem, + RepreItem, +) + +PRODUCTS_MODEL_SENDER = "products.model" + + +def version_item_from_entity(version): + version_attribs = version["attrib"] + frame_start = version_attribs.get("frameStart") + frame_end = version_attribs.get("frameEnd") + handle_start = version_attribs.get("handleStart") + handle_end = version_attribs.get("handleEnd") + step = version_attribs.get("step") + comment = version_attribs.get("comment") + source = version_attribs.get("source") + + frame_range = None + duration = None + handles = None + if frame_start is not None and frame_end is not None: + # Remove superfluous zeros from numbers (3.0 -> 3) to improve + # readability for most frame ranges + frame_start = int(frame_start) + frame_end = int(frame_end) + frame_range = "{}-{}".format(frame_start, frame_end) + duration = frame_end - frame_start + 1 + + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(int(handle_start), int(handle_end)) + + # NOTE There is also 'updatedAt', should be used that instead? + # TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because + # 'PrettyTimeDelegate' expects it + created_at = arrow.get(version["createdAt"]) + published_time = created_at.strftime("%Y%m%dT%H%M%SZ") + author = version["author"] + version_num = version["version"] + is_hero = version_num < 0 + + return VersionItem( + version_id=version["id"], + version=version_num, + is_hero=is_hero, + product_id=version["productId"], + thumbnail_id=version["thumbnailId"], + published_time=published_time, + author=author, + frame_range=frame_range, + duration=duration, + handles=handles, + step=step, + comment=comment, + source=source, + ) + + +def product_item_from_entity( + product_entity, + version_entities, + product_type_items_by_name, + folder_label, + product_in_scene, +): + product_attribs = product_entity["attrib"] + group = product_attribs.get("productGroup") + product_type = product_entity["productType"] + product_type_item = product_type_items_by_name[product_type] + product_type_icon = product_type_item.icon + + product_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + version_items = { + version_entity["id"]: version_item_from_entity(version_entity) + for version_entity in version_entities + } + + return ProductItem( + product_id=product_entity["id"], + product_type=product_type, + product_name=product_entity["name"], + product_icon=product_icon, + product_type_icon=product_type_icon, + product_in_scene=product_in_scene, + group_name=group, + folder_id=product_entity["folderId"], + folder_label=folder_label, + version_items=version_items, + ) + + +def product_type_item_from_data(product_type_data): + # TODO implement icon implementation + # icon = product_type_data["icon"] + # color = product_type_data["color"] + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + # TODO implement checked logic + return ProductTypeItem(product_type_data["name"], icon, True) + + +class ProductsModel: + """Model for products, version and representation. + + All of the entities are product based. This model prepares data for UI + and caches it for faster access. + + Note: + Data are not used for actions model because that would require to + break OpenPype compatibility of 'LoaderPlugin's. + """ + + lifetime = 60 # In seconds (minute by default) + + def __init__(self, controller): + self._controller = controller + + # Mapping helpers + # NOTE - mapping must be cleaned up with cache cleanup + self._product_item_by_id = collections.defaultdict(dict) + self._version_item_by_id = collections.defaultdict(dict) + self._product_folder_ids_mapping = collections.defaultdict(dict) + + # Cache helpers + self._product_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) + self._product_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._repre_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + + def reset(self): + """Reset model with all cached data.""" + + self._product_item_by_id.clear() + self._version_item_by_id.clear() + self._product_folder_ids_mapping.clear() + + self._product_type_items_cache.reset() + self._product_items_cache.reset() + self._repre_items_cache.reset() + + def get_product_type_items(self, project_name): + """Product type items for project. + + Args: + project_name (str): Project name. + + Returns: + list[ProductTypeItem]: Product type items. + """ + + cache = self._product_type_items_cache[project_name] + if not cache.is_valid: + product_types = ayon_api.get_project_product_types(project_name) + cache.update_data([ + product_type_item_from_data(product_type) + for product_type in product_types + ]) + return cache.get_data() + + def get_product_items(self, project_name, folder_ids, sender): + """Product items with versions for project and folder ids. + + Product items also contain version items. They're directly connected + to product items in the UI and the separation is not needed. + + Args: + project_name (Union[str, None]): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[ProductItem]: Product items. + """ + + if not project_name or not folder_ids: + return [] + + project_cache = self._product_items_cache[project_name] + output = [] + folder_ids_to_update = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output.extend(cache.get_data().values()) + else: + folder_ids_to_update.add(folder_id) + + self._refresh_product_items( + project_name, folder_ids_to_update, sender) + + for folder_id in folder_ids_to_update: + cache = project_cache[folder_id] + output.extend(cache.get_data().values()) + return output + + def get_product_item(self, project_name, product_id): + """Get product item based on passed product id. + + This method is using cached items, but if cache is not valid it also + can query the item. + + Args: + project_name (Union[str, None]): Where to look for product. + product_id (Union[str, None]): Product id to receive. + + Returns: + Union[ProductItem, None]: Product item or 'None' if not found. + """ + + if not any((project_name, product_id)): + return None + + product_items_by_id = self._product_item_by_id[project_name] + product_item = product_items_by_id.get(product_id) + if product_item is not None: + return product_item + for product_item in self._query_product_items_by_ids( + project_name, product_ids=[product_id] + ).values(): + return product_item + + def get_product_ids_by_repre_ids(self, project_name, repre_ids): + """Get product ids based on passed representation ids. + + Args: + project_name (str): Where to look for representations. + repre_ids (Iterable[str]): Representation ids. + + Returns: + set[str]: Product ids for passed representation ids. + """ + + # TODO look out how to use single server call + if not repre_ids: + return set() + repres = ayon_api.get_representations( + project_name, repre_ids, fields=["versionId"] + ) + version_ids = {repre["versionId"] for repre in repres} + if not version_ids: + return set() + versions = ayon_api.get_versions( + project_name, version_ids=version_ids, fields=["productId"] + ) + return {v["productId"] for v in versions} + + def get_repre_items(self, project_name, version_ids, sender): + """Get representation items for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[RepreItem]: Representation items. + """ + + output = [] + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output.extend(version_cache.get_data().values()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output.extend(version_cache.get_data().values()) + + return output + + def change_products_group(self, project_name, product_ids, group_name): + """Change group name for passed product ids. + + Group name is stored in 'attrib' of product entity and is used in UI + to group items. + + Method triggers "products.group.changed" event with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids to change group name for. + group_name (str): Group name to set. + """ + + if not product_ids: + return + + product_items = self._get_product_items_by_id( + project_name, product_ids + ) + if not product_items: + return + + session = OperationsSession() + folder_ids = set() + for product_item in product_items.values(): + session.update_entity( + project_name, + "product", + product_item.product_id, + {"attrib": {"productGroup": group_name}} + ) + folder_ids.add(product_item.folder_id) + product_item.group_name = group_name + + session.commit() + self._controller.emit_event( + "products.group.changed", + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + }, + PRODUCTS_MODEL_SENDER + ) + + def _get_product_items_by_id(self, project_name, product_ids): + product_item_by_id = self._product_item_by_id[project_name] + missing_product_ids = set() + output = {} + for product_id in product_ids: + product_item = product_item_by_id.get(product_id) + if product_item is not None: + output[product_id] = product_item + else: + missing_product_ids.add(product_id) + + output.update( + self._query_product_items_by_ids( + project_name, missing_product_ids + ) + ) + return output + + def _get_version_items_by_id(self, project_name, version_ids): + version_item_by_id = self._version_item_by_id[project_name] + missing_version_ids = set() + output = {} + for version_id in version_ids: + version_item = version_item_by_id.get(version_id) + if version_item is not None: + output[version_id] = version_item + else: + missing_version_ids.add(version_id) + + output.update( + self._query_version_items_by_ids( + project_name, missing_version_ids + ) + ) + return output + + def _create_product_items( + self, + project_name, + products, + versions, + folder_items=None, + product_type_items=None, + ): + if folder_items is None: + folder_items = self._controller.get_folder_items(project_name) + + if product_type_items is None: + product_type_items = self.get_product_type_items(project_name) + + loaded_product_ids = self._controller.get_loaded_product_ids() + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + product_type_items_by_name = { + product_type_item.name: product_type_item + for product_type_item in product_type_items + } + output = {} + for product in products: + product_id = product["id"] + folder_id = product["folderId"] + folder_item = folder_items.get(folder_id) + if not folder_item: + continue + versions = versions_by_product_id[product_id] + if not versions: + continue + product_item = product_item_from_entity( + product, + versions, + product_type_items_by_name, + folder_item.label, + product_id in loaded_product_ids, + ) + output[product_id] = product_item + return output + + def _query_product_items_by_ids( + self, + project_name, + folder_ids=None, + product_ids=None, + folder_items=None + ): + """Query product items. + + This method does get from, or store to, cache attributes. + + One of 'product_ids' or 'folder_ids' must be passed to the method. + + Args: + project_name (str): Project name. + folder_ids (Optional[Iterable[str]]): Folder ids under which are + products. + product_ids (Optional[Iterable[str]]): Product ids to use. + folder_items (Optional[Dict[str, FolderItem]]): Prepared folder + items from controller. + + Returns: + dict[str, ProductItem]: Product items by product id. + """ + + if not folder_ids and not product_ids: + return {} + + kwargs = {} + if folder_ids is not None: + kwargs["folder_ids"] = folder_ids + + if product_ids is not None: + kwargs["product_ids"] = product_ids + + products = list(ayon_api.get_products(project_name, **kwargs)) + product_ids = {product["id"] for product in products} + + versions = ayon_api.get_versions( + project_name, product_ids=product_ids + ) + + return self._create_product_items( + project_name, products, versions, folder_items=folder_items + ) + + def _query_version_items_by_ids(self, project_name, version_ids): + versions = list(ayon_api.get_versions( + project_name, version_ids=version_ids + )) + product_ids = {version["productId"] for version in versions} + products = list(ayon_api.get_products( + project_name, product_ids=product_ids + )) + product_items = self._create_product_items( + project_name, products, versions + ) + version_items = {} + for product_item in product_items.values(): + version_items.update(product_item.version_items) + return version_items + + def _clear_product_version_items(self, project_name, folder_ids): + """Clear product and version items from memory. + + When products are re-queried for a folders, the old product and version + items in '_product_item_by_id' and '_version_item_by_id' should + be cleaned up from memory. And mapping in stored in + '_product_folder_ids_mapping' is not relevant either. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + """ + + project_mapping = self._product_folder_ids_mapping[project_name] + if not project_mapping: + return + + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + for folder_id in folder_ids: + product_ids = project_mapping.pop(folder_id, None) + if not product_ids: + continue + + for product_id in product_ids: + product_item = product_item_by_id.pop(product_id, None) + if product_item is None: + continue + for version_item in product_item.version_items.values(): + version_item_by_id.pop(version_item.version_id, None) + + def _refresh_product_items(self, project_name, folder_ids, sender): + """Refresh product items and store them in cache. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + sender (Union[str, None]): Who triggered the refresh. + """ + + if not project_name or not folder_ids: + return + + self._clear_product_version_items(project_name, folder_ids) + + project_mapping = self._product_folder_ids_mapping[project_name] + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + + for folder_id in folder_ids: + project_mapping[folder_id] = set() + + with self._product_refresh_event_manager( + project_name, folder_ids, sender + ): + folder_items = self._controller.get_folder_items(project_name) + items_by_folder_id = { + folder_id: {} + for folder_id in folder_ids + } + product_items_by_id = self._query_product_items_by_ids( + project_name, + folder_ids=folder_ids, + folder_items=folder_items + ) + for product_id, product_item in product_items_by_id.items(): + folder_id = product_item.folder_id + items_by_folder_id[product_item.folder_id][product_id] = ( + product_item + ) + + project_mapping[folder_id].add(product_id) + product_item_by_id[product_id] = product_item + for version_id, version_item in ( + product_item.version_items.items() + ): + version_item_by_id[version_id] = version_item + + project_cache = self._product_items_cache[project_name] + for folder_id, product_items in items_by_folder_id.items(): + project_cache[folder_id].update_data(product_items) + + @contextlib.contextmanager + def _product_refresh_event_manager( + self, project_name, folder_ids, sender + ): + self._controller.emit_event( + "products.refresh.started", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "products.refresh.finished", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + + def refresh_representation_items( + self, project_name, version_ids, sender + ): + if not any((project_name, version_ids)): + return + self._controller.emit_event( + "model.representations.refresh.started", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + failed = False + try: + self._refresh_representation_items(project_name, version_ids) + except Exception: + # TODO add more information about failed refresh + failed = True + + self._controller.emit_event( + "model.representations.refresh.finished", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + "failed": failed, + }, + PRODUCTS_MODEL_SENDER + ) + + def _refresh_representation_items(self, project_name, version_ids): + representations = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + fields=["id", "name", "versionId"] + )) + + version_items_by_id = self._get_version_items_by_id( + project_name, version_ids + ) + product_ids = { + version_item.product_id + for version_item in version_items_by_id.values() + } + product_items_by_id = self._get_product_items_by_id( + project_name, product_ids + ) + repre_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + repre_items_by_version_id = collections.defaultdict(dict) + for representation in representations: + version_id = representation["versionId"] + version_item = version_items_by_id.get(version_id) + if version_item is None: + continue + product_item = product_items_by_id.get(version_item.product_id) + if product_item is None: + continue + repre_id = representation["id"] + repre_item = RepreItem( + repre_id, + representation["name"], + repre_icon, + product_item.product_name, + product_item.folder_label, + ) + repre_items_by_version_id[version_id][repre_id] = repre_item + + project_cache = self._repre_items_cache[project_name] + for version_id, repre_items in repre_items_by_version_id.items(): + version_cache = project_cache[version_id] + version_cache.update_data(repre_items) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py new file mode 100644 index 0000000000..326ff835f6 --- /dev/null +++ b/openpype/tools/ayon_loader/models/selection.py @@ -0,0 +1,85 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folders.changed" + - "selection.versions.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_ids = set() + self._version_ids = set() + self._representation_ids = set() + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if self._project_name == project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": self._project_name}, + self.event_source + ) + + def get_selected_folder_ids(self): + return self._folder_ids + + def set_selected_folders(self, folder_ids): + if folder_ids == self._folder_ids: + return + + self._folder_ids = folder_ids + self._controller.emit_event( + "selection.folders.changed", + { + "project_name": self._project_name, + "folder_ids": folder_ids, + }, + self.event_source + ) + + def get_selected_version_ids(self): + return self._version_ids + + def set_selected_versions(self, version_ids): + if version_ids == self._version_ids: + return + + self._version_ids = version_ids + self._controller.emit_event( + "selection.versions.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + }, + self.event_source + ) + + def get_selected_representation_ids(self): + return self._representation_ids + + def set_selected_representations(self, repre_ids): + if repre_ids == self._representation_ids: + return + + self._representation_ids = repre_ids + self._controller.emit_event( + "selection.representations.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + "representation_ids": self._representation_ids, + } + ) diff --git a/openpype/tools/ayon_loader/ui/__init__.py b/openpype/tools/ayon_loader/ui/__init__.py new file mode 100644 index 0000000000..41e4418641 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LoaderWindow + + +__all__ = ( + "LoaderWindow", +) diff --git a/openpype/tools/ayon_loader/ui/actions_utils.py b/openpype/tools/ayon_loader/ui/actions_utils.py new file mode 100644 index 0000000000..a269b643dc --- /dev/null +++ b/openpype/tools/ayon_loader/ui/actions_utils.py @@ -0,0 +1,118 @@ +import uuid + +from qtpy import QtWidgets, QtGui +import qtawesome + +from openpype.lib.attribute_definitions import AbstractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog +from openpype.tools.utils.widgets import ( + OptionalMenu, + OptionalAction, + OptionDialog, +) +from openpype.tools.ayon_utils.widgets import get_qt_icon + + +def show_actions_menu(action_items, global_point, one_item_selected, parent): + selected_action_item = None + selected_options = None + + if not action_items: + menu = QtWidgets.QMenu(parent) + action = _get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + menu.exec_(global_point) + return selected_action_item, selected_options + + menu = OptionalMenu(parent) + + action_items_by_id = {} + for action_item in action_items: + item_id = uuid.uuid4().hex + action_items_by_id[item_id] = action_item + item_options = action_item.options + icon = get_qt_icon(action_item.icon) + use_option = bool(item_options) + action = OptionalAction( + action_item.label, + icon, + use_option, + menu + ) + if use_option: + # Add option box tip + action.set_option_tip(item_options) + + tip = action_item.tooltip + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + action.setData(item_id) + + menu.addAction(action) + + action = menu.exec_(global_point) + if action is not None: + item_id = action.data() + selected_action_item = action_items_by_id.get(item_id) + + if selected_action_item is not None: + selected_options = _get_options(action, selected_action_item, parent) + + return selected_action_item, selected_options + + +def _get_options(action, action_item, parent): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + AttributeDefinitions, and for backwards compatibility qargparse. + + Args: + action (OptionalAction) - Action object in menu. + action_item (ActionItem) - Action item with context information. + parent (QtCore.QObject) - Parent object for dialog. + + Returns: + Union[dict[str, Any], None]: Selected value from attributes or + 'None' if dialog was cancelled. + """ + + # Pop option dialog + options = action_item.options + if not getattr(action, "optioned", False) or not options: + return {} + + if isinstance(options[0], AbstractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(options, parent) + else: + qargparse_options = True + dialog = OptionDialog(parent) + dialog.create(options) + + dialog.setWindowTitle(action.label + " Options") + + if not dialog.exec_(): + return None + + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() + + +def _get_no_loader_action(menu, one_item_selected): + """Creates dummy no loader option in 'menu'""" + + if one_item_selected: + submsg = "this version." + else: + submsg = "your selection." + msg = "No compatible loaders for {}".format(submsg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + return QtWidgets.QAction(icon, ("*" + msg), menu) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py new file mode 100644 index 0000000000..b911458546 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -0,0 +1,416 @@ +import qtpy +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.style import get_objected_colors + +from openpype.tools.ayon_utils.widgets import ( + FoldersModel, + FOLDERS_MODEL_SENDER_NAME, +) +from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE + +if qtpy.API == "pyside": + from PySide.QtGui import QStyleOptionViewItemV4 +elif qtpy.API == "pyqt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 + + +class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): + """Item delegate drawing bars under folder label. + + This is used in loader tool. Multiselection of folders + may group products by name under colored groups. Selected color groups are + then propagated back to selected folders as underlines. + """ + bar_height = 3 + + def __init__(self, *args, **kwargs): + super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs) + colors = get_objected_colors("loader", "asset-view") + self._selected_color = colors["selected"].get_qcolor() + self._hover_color = colors["hover"].get_qcolor() + self._selected_hover_color = colors["selected-hover"].get_qcolor() + + def sizeHint(self, option, index): + """Add bar height to size hint.""" + result = super(UnderlinesFolderDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + """Replicate painting of an item and draw color bars if needed.""" + # Qt4 compat + if qtpy.API in ("pyside", "pyqt4"): + option = QStyleOptionViewItemV4(option) + + painter.save() + + item_rect = QtCore.QRect(option.rect) + item_rect.setHeight(option.rect.height() - self.bar_height) + + subset_colors = index.data(UNDERLINE_COLORS_ROLE) or [] + + subset_colors_width = 0 + if subset_colors: + subset_colors_width = option.rect.width() / len(subset_colors) + + subset_rects = [] + counter = 0 + for subset_c in subset_colors: + new_color = None + new_rect = None + if subset_c: + new_color = QtGui.QColor(subset_c) + + new_rect = QtCore.QRect( + option.rect.left() + (counter * subset_colors_width), + option.rect.top() + ( + option.rect.height() - self.bar_height + ), + subset_colors_width, + self.bar_height + ) + subset_rects.append((new_color, new_rect)) + counter += 1 + + # Background + if option.state & QtWidgets.QStyle.State_Selected: + if len(subset_colors) == 0: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._selected_hover_color + else: + bg_color = self._selected_color + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + else: + bg_color = QtGui.QColor() + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + painter.fillRect( + option.rect, + QtGui.QBrush(bg_color) + ) + + if option.state & QtWidgets.QStyle.State_Selected: + for color, subset_rect in subset_rects: + if not color or not subset_rect: + continue + painter.fillRect(subset_rect, QtGui.QBrush(color)) + + # Icon + icon_index = index.model().index( + index.row(), index.column(), index.parent() + ) + # - Default icon_rect if not icon + icon_rect = QtCore.QRect( + item_rect.left(), + item_rect.top(), + # To make sure it's same size all the time + option.rect.height() - self.bar_height, + option.rect.height() - self.bar_height + ) + icon = index.model().data(icon_index, QtCore.Qt.DecorationRole) + + if icon: + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + + if isinstance(icon, QtGui.QPixmap): + icon = QtGui.QIcon(icon) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QColor): + pixmap = QtGui.QPixmap(option.decorationSize) + pixmap.fill(icon) + icon = QtGui.QIcon(pixmap) + + elif isinstance(icon, QtGui.QImage): + icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon)) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QIcon): + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + actual_size = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actual_size.width()), + min(option.decorationSize.height(), actual_size.height()) + ) + + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + icon.paint( + painter, icon_rect, + QtCore.Qt.AlignLeft, mode, state + ) + + # Text + text_rect = QtCore.QRect( + icon_rect.left() + icon_rect.width() + 2, + item_rect.top(), + item_rect.width(), + item_rect.height() + ) + + painter.drawText( + text_rect, QtCore.Qt.AlignVCenter, + index.data(QtCore.Qt.DisplayRole) + ) + + painter.restore() + + +class LoaderFoldersModel(FoldersModel): + def __init__(self, *args, **kwargs): + super(LoaderFoldersModel, self).__init__(*args, **kwargs) + + self._colored_items = set() + + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + super(LoaderFoldersModel, self)._fill_item_data(item, folder_item) + + def set_merged_products_selection(self, items): + changes = { + folder_id: None + for folder_id in self._colored_items + } + + all_folder_ids = set() + for item in items: + folder_ids = item["folder_ids"] + all_folder_ids.update(folder_ids) + + for folder_id in all_folder_ids: + changes[folder_id] = [] + + for item in items: + item_color = item["color"] + item_folder_ids = item["folder_ids"] + for folder_id in all_folder_ids: + folder_color = ( + item_color + if folder_id in item_folder_ids + else None + ) + changes[folder_id].append(folder_color) + + for folder_id, color_value in changes.items(): + item = self._items_by_id.get(folder_id) + if item is not None: + item.setData(color_value, UNDERLINE_COLORS_ROLE) + + self._colored_items = all_folder_ids + + +class LoaderFoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + handle_expected_selection (bool): If True, the widget will handle + the expected selection. Defaults to False. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller, parent, handle_expected_selection=False): + super(LoaderFoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + folders_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + + folders_model = LoaderFoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + folders_label_delegate = UnderlinesFolderDelegate(folders_view) + + folders_view.setModel(folders_proxy_model) + folders_view.setItemDelegate(folders_label_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + self._folders_label_delegate = folders_label_delegate + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + def set_name_filer(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def set_merged_products_selection(self, items): + """ + + Args: + items (list[dict[str, Any]]): List of merged items with folder + ids. + """ + + self._folders_model.set_merged_products_selection(items) + + def refresh(self): + self._folders_model.refresh() + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._set_project_name(project_name) + + def _set_project_name(self, project_name): + self._folders_model.set_project_name(project_name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._folders_view.selectionModel() + item_ids = [] + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is not None: + item_ids.append(item_id) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_folders(item_ids) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + + folder_id = self._expected_selection + selected_ids = self._get_selected_item_ids() + self._expected_selection = None + skip_selection = ( + folder_id is None + or ( + folder_id in selected_ids + and len(selected_ids) == 1 + ) + ) + if not skip_selection: + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py new file mode 100644 index 0000000000..b7d1b0811f --- /dev/null +++ b/openpype/tools/ayon_loader/ui/info_widget.py @@ -0,0 +1,141 @@ +import datetime + +from qtpy import QtWidgets + +from openpype.tools.utils.lib import format_version + + +class VersionTextEdit(QtWidgets.QTextEdit): + """QTextEdit that displays version specific information. + + This also overrides the context menu to add actions like copying + source path to clipboard or copying the raw data of the version + to clipboard. + + """ + def __init__(self, controller, parent): + super(VersionTextEdit, self).__init__(parent=parent) + + self._version_item = None + self._product_item = None + + self._controller = controller + + # Reset + self.set_current_item() + + def set_current_item(self, product_item=None, version_item=None): + """ + + Args: + product_item (Union[ProductItem, None]): Product item. + version_item (Union[VersionItem, None]): Version item to display. + """ + + self._product_item = product_item + self._version_item = version_item + + if version_item is None: + # Reset state to empty + self.setText("") + return + + version_label = format_version(abs(version_item.version)) + if version_item.version < 0: + version_label = "Hero version {}".format(version_label) + + # Define readable creation timestamp + created = version_item.published_time + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_item.comment or "No comment" + source = version_item.source or "No source" + + self.setHtml( + ( + "

{product_name}

" + "

{version_label}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format( + product_name=product_item.product_name, + version_label=version_label, + comment=comment, + created=created, + source=source, + ) + ) + + def contextMenuEvent(self, event): + """Context menu with additional actions""" + menu = self.createStandardContextMenu() + + # Add additional actions when any text, so we can assume + # the version is set. + source = None + if self._version_item is not None: + source = self._version_item.source + + if source: + menu.addSeparator() + action = QtWidgets.QAction( + "Copy source path to clipboard", menu + ) + action.triggered.connect(self._on_copy_source) + menu.addAction(action) + + menu.exec_(event.globalPos()) + + def _on_copy_source(self): + """Copy formatted source path to clipboard.""" + + source = self._version_item.source + if not source: + return + + filled_source = self._controller.fill_root_in_source(source) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(filled_source) + + +class InfoWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, controller, parent): + super(InfoWidget, self).__init__(parent=parent) + + label_widget = QtWidgets.QLabel("Version Info", self) + info_text_widget = VersionTextEdit(controller, self) + info_text_widget.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(info_text_widget, 1) + + self._controller = controller + + self._info_text_widget = info_text_widget + self._label_widget = label_widget + + def set_selected_version_info(self, project_name, items): + if not items or not project_name: + self._info_text_widget.set_current_item() + return + first_item = next(iter(items)) + product_item = self._controller.get_product_item( + project_name, + first_item["product_id"], + ) + version_id = first_item["version_id"] + version_item = None + if product_item is not None: + version_item = product_item.version_items.get(version_id) + + self._info_text_widget.set_current_item(product_item, version_item) diff --git a/openpype/tools/ayon_loader/ui/product_group_dialog.py b/openpype/tools/ayon_loader/ui/product_group_dialog.py new file mode 100644 index 0000000000..5737ce58a4 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_group_dialog.py @@ -0,0 +1,45 @@ +from qtpy import QtWidgets + +from openpype.tools.utils import PlaceholderLineEdit + + +class ProductGroupDialog(QtWidgets.QDialog): + def __init__(self, controller, parent): + super(ProductGroupDialog, self).__init__(parent) + self.setWindowTitle("Grouping products") + self.setMinimumWidth(250) + self.setModal(True) + + main_label = QtWidgets.QLabel("Group Name", self) + + group_name_input = PlaceholderLineEdit(self) + group_name_input.setPlaceholderText("Remain blank to ungroup..") + + group_btn = QtWidgets.QPushButton("Apply", self) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(main_label, 0) + layout.addWidget(group_name_input, 0) + layout.addWidget(group_btn, 0) + + group_btn.clicked.connect(self._on_apply_click) + + self._project_name = None + self._product_ids = set() + + self._controller = controller + self._group_btn = group_btn + self._group_name_input = group_name_input + + def set_product_ids(self, project_name, product_ids): + self._project_name = project_name + self._product_ids = product_ids + + def _on_apply_click(self): + group_name = self._group_name_input.text().strip() or None + self._controller.change_products_group( + self._project_name, self._product_ids, group_name + ) + self.close() diff --git a/openpype/tools/ayon_loader/ui/product_types_widget.py b/openpype/tools/ayon_loader/ui/product_types_widget.py new file mode 100644 index 0000000000..a84a7ff846 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_types_widget.py @@ -0,0 +1,220 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 + + +class ProductTypesQtModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + filter_changed = QtCore.Signal() + + def __init__(self, controller): + super(ProductTypesQtModel, self).__init__() + self._controller = controller + + self._refreshing = False + self._bulk_change = False + self._items_by_name = {} + + def is_refreshing(self): + return self._refreshing + + def get_filter_info(self): + """Product types filtering info. + + Returns: + dict[str, bool]: Filtering value by product type name. False value + means to hide product type. + """ + + return { + name: item.checkState() == QtCore.Qt.Checked + for name, item in self._items_by_name.items() + } + + def refresh(self, project_name): + self._refreshing = True + product_type_items = self._controller.get_product_type_items( + project_name) + + items_to_remove = set(self._items_by_name.keys()) + new_items = [] + for product_type_item in product_type_items: + name = product_type_item.name + items_to_remove.discard(name) + item = self._items_by_name.get(product_type_item.name) + if item is None: + item = QtGui.QStandardItem(name) + item.setData(name, PRODUCT_TYPE_ROLE) + item.setEditable(False) + item.setCheckable(True) + new_items.append(item) + self._items_by_name[name] = item + + item.setCheckState( + QtCore.Qt.Checked + if product_type_item.checked + else QtCore.Qt.Unchecked + ) + icon = get_qt_icon(product_type_item.icon) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + if new_items: + root_item.appendRows(new_items) + + for name in items_to_remove: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + self._refreshing = False + self.refreshed.emit() + + def setData(self, index, value, role=None): + checkstate_changed = False + if role is None: + role = QtCore.Qt.EditRole + elif role == QtCore.Qt.CheckStateRole: + checkstate_changed = True + output = super(ProductTypesQtModel, self).setData(index, value, role) + if checkstate_changed and not self._bulk_change: + self.filter_changed.emit() + return output + + def change_state_for_all(self, checked): + if self._items_by_name: + self.change_states(checked, self._items_by_name.keys()) + + def change_states(self, checked, product_types): + product_types = set(product_types) + if not product_types: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self._bulk_change = True + + changed = False + for product_type in product_types: + item = self._items_by_name.get(product_type) + if item is None: + continue + new_state = state + item_checkstate = item.checkState() + if new_state is None: + if item_checkstate == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + elif item_checkstate == new_state: + continue + changed = True + item.setCheckState(new_state) + + self._bulk_change = False + + if changed: + self.filter_changed.emit() + + +class ProductTypesView(QtWidgets.QListView): + filter_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(ProductTypesView, self).__init__(parent) + + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + product_types_model = ProductTypesQtModel(controller) + product_types_proxy_model = QtCore.QSortFilterProxyModel() + product_types_proxy_model.setSourceModel(product_types_model) + + self.setModel(product_types_proxy_model) + + product_types_model.refreshed.connect(self._on_refresh_finished) + product_types_model.filter_changed.connect(self._on_filter_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + + self._controller = controller + + self._product_types_model = product_types_model + self._product_types_proxy_model = product_types_proxy_model + + def get_filter_info(self): + return self._product_types_model.get_filter_info() + + def _on_project_change(self, event): + project_name = event["project_name"] + self._product_types_model.refresh(project_name) + + def _on_refresh_finished(self): + self.filter_changed.emit() + + def _on_filter_change(self): + if not self._product_types_model.is_refreshing(): + self.filter_changed.emit() + + def _change_selection_state(self, checkstate): + selection_model = self.selectionModel() + product_types = { + index.data(PRODUCT_TYPE_ROLE) + for index in selection_model.selectedIndexes() + } + product_types.discard(None) + self._product_types_model.change_states(checkstate, product_types) + + def _on_enable_all(self): + self._product_types_model.change_state_for_all(True) + + def _on_disable_all(self): + self._product_types_model.change_state_for_all(False) + + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self._on_enable_all) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self._on_disable_all) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + if event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + if event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(ProductTypesView, self).event(event) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py new file mode 100644 index 0000000000..6729468bfa --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -0,0 +1,191 @@ +import numbers +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils.lib import format_version + +from .products_model import ( + PRODUCT_ID_ROLE, + VERSION_NAME_EDIT_ROLE, + VERSION_ID_ROLE, + PRODUCT_IN_SCENE_ROLE, +) + + +class VersionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal(str) + + def __init__(self, product_id, parent): + super(VersionComboBox, self).__init__(parent) + self._product_id = product_id + self._items_by_id = {} + + self._current_id = None + + self.currentIndexChanged.connect(self._on_index_change) + + def update_versions(self, version_items, current_version_id): + model = self.model() + root_item = model.invisibleRootItem() + version_items = list(reversed(version_items)) + version_ids = [ + version_item.version_id + for version_item in version_items + ] + if current_version_id not in version_ids and version_ids: + current_version_id = version_ids[0] + self._current_id = current_version_id + + to_remove = set(self._items_by_id.keys()) - set(version_ids) + for item_id in to_remove: + item = self._items_by_id.pop(item_id) + root_item.removeRow(item.row()) + + for idx, version_item in enumerate(version_items): + version_id = version_item.version_id + + item = self._items_by_id.get(version_id) + if item is None: + label = format_version( + abs(version_item.version), version_item.is_hero + ) + item = QtGui.QStandardItem(label) + item.setData(version_id, QtCore.Qt.UserRole) + self._items_by_id[version_id] = item + + if item.row() != idx: + root_item.insertRow(idx, item) + + index = version_ids.index(current_version_id) + if self.currentIndex() != index: + self.setCurrentIndex(index) + + def _on_index_change(self): + idx = self.currentIndex() + value = self.itemData(idx) + if value == self._current_id: + return + self._current_id = value + self.value_changed.emit(self._product_id) + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(VersionDelegate, self).__init__(*args, **kwargs) + self._editor_by_product_id = {} + + def displayText(self, value, locale): + if not isinstance(value, numbers.Integral): + return "N/A" + return format_version(abs(value), value < 0) + + def paint(self, painter, option, index): + fg_color = index.data(QtCore.Qt.ForegroundRole) + if fg_color: + if isinstance(fg_color, QtGui.QBrush): + fg_color = fg_color.color() + elif isinstance(fg_color, QtGui.QColor): + pass + else: + fg_color = None + + if not fg_color: + return super(VersionDelegate, self).paint(painter, option, index) + + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + style.drawControl( + style.CE_ItemViewItem, option, painter, option.widget + ) + + painter.save() + + text = self.displayText( + index.data(QtCore.Qt.DisplayRole), option.locale + ) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + text_rect = style.subElementRect(style.SE_ItemViewItemText, option) + text_margin = style.proxy().pixelMetric( + style.PM_FocusFrameHMargin, option, option.widget + ) + 1 + + painter.drawText( + text_rect.adjusted(text_margin, 0, - text_margin, 0), + option.displayAlignment, + text + ) + + painter.restore() + + def createEditor(self, parent, option, index): + product_id = index.data(PRODUCT_ID_ROLE) + if not product_id: + return + + editor = VersionComboBox(product_id, parent) + self._editor_by_product_id[product_id] = editor + editor.value_changed.connect(self._on_editor_change) + + return editor + + def _on_editor_change(self, product_id): + editor = self._editor_by_product_id[product_id] + + # Update model data + self.commitData.emit(editor) + # Display model data + self.version_changed.emit() + + def setEditorData(self, editor, index): + editor.clear() + + # Current value of the index + versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + + version_id = editor.itemData(editor.currentIndex()) + model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) + + +class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for Loaded in Scene state columns. + + Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values. + Colorizes green or dark grey based on values. + """ + + def __init__(self, *args, **kwargs): + super(LoadedInSceneDelegate, self).__init__(*args, **kwargs) + self._colors = { + 1: QtGui.QColor(80, 170, 80), + 0: QtGui.QColor(90, 90, 90), + } + self._default_color = QtGui.QColor(90, 90, 90) + + def displayText(self, value, locale): + if value == 0: + return "No" + elif value == 1: + return "Yes" + return "N/A" + + def initStyleOption(self, option, index): + super(LoadedInSceneDelegate, self).initStyleOption(option, index) + + # Colorize based on value + value = index.data(PRODUCT_IN_SCENE_ROLE) + color = self._colors.get(value, self._default_color) + option.palette.setBrush(QtGui.QPalette.Text, color) diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py new file mode 100644 index 0000000000..741f15766b --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -0,0 +1,590 @@ +import collections + +import qtawesome +from qtpy import QtGui, QtCore + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" + +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 +MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 + + +class ProductsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + version_changed = QtCore.Signal() + column_labels = [ + "Product name", + "Product type", + "Folder", + "Version", + "Time", + "Author", + "Frames", + "Duration", + "Handles", + "Step", + "In scene", + "Availability", + ] + merged_items_colors = [ + ("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c)) + for c in [ + (55, 161, 222), # Light Blue + (231, 176, 0), # Yellow + (154, 13, 255), # Purple + (130, 184, 30), # Light Green + (211, 79, 63), # Light Red + (179, 181, 182), # Grey + (194, 57, 179), # Pink + (0, 120, 215), # Dark Blue + (0, 204, 106), # Dark Green + (247, 99, 12), # Orange + ] + ] + + version_col = column_labels.index("Version") + published_time_col = column_labels.index("Time") + folders_label_col = column_labels.index("Folder") + in_scene_col = column_labels.index("In scene") + + def __init__(self, controller): + super(ProductsModel, self).__init__() + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + self._controller = controller + + # Variables to store 'QStandardItem' + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + + # product item objects (they have version information) + self._product_items_by_id = {} + self._grouping_enabled = True + self._reset_merge_color = False + self._color_iterator = self._color_iter() + self._group_icon = None + + self._last_project_name = None + self._last_folder_ids = [] + + def get_product_item_indexes(self): + return [ + item.index() + for item in self._items_by_id.values() + ] + + def get_product_item_by_id(self, product_id): + """ + + Args: + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product item with version information. + """ + + return self._product_items_by_id.get(product_id) + + def set_enable_grouping(self, enable_grouping): + if enable_grouping is self._grouping_enabled: + return + self._grouping_enabled = enable_grouping + # Ignore change if groups are not available + self.refresh(self._last_project_name, self._last_folder_ids) + + def flags(self, index): + # Make the version column editable + if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): + return ( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEditable + ) + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(ProductsModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if not index.isValid(): + return None + + col = index.column() + if col == 0: + return super(ProductsModel, self).data(index, role) + + if role == QtCore.Qt.DecorationRole: + if col == 1: + role = PRODUCT_TYPE_ICON_ROLE + else: + return None + + if ( + role == VERSION_NAME_EDIT_ROLE + or (role == QtCore.Qt.EditRole and col == self.version_col) + ): + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + return list(product_item.version_items.values()) + + if role == QtCore.Qt.EditRole: + return None + + if role == QtCore.Qt.DisplayRole: + if not index.data(PRODUCT_ID_ROLE): + return None + if col == self.version_col: + role = VERSION_NAME_ROLE + elif col == 1: + role = PRODUCT_TYPE_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + elif col == 4: + role = VERSION_PUBLISH_TIME_ROLE + elif col == 5: + role = VERSION_AUTHOR_ROLE + elif col == 6: + role = VERSION_FRAME_RANGE_ROLE + elif col == 7: + role = VERSION_DURATION_ROLE + elif col == 8: + role = VERSION_HANDLES_ROLE + elif col == 9: + role = VERSION_STEP_ROLE + elif col == 10: + role = PRODUCT_IN_SCENE_ROLE + elif col == 11: + role = VERSION_AVAILABLE_ROLE + else: + return None + + index = self.index(index.row(), 0, index.parent()) + + return super(ProductsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if not index.isValid(): + return False + + if role is None: + role = QtCore.Qt.EditRole + + col = index.column() + if col == self.version_col and role == QtCore.Qt.EditRole: + role = VERSION_NAME_EDIT_ROLE + + if role == VERSION_NAME_EDIT_ROLE: + if col != 0: + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id[product_id] + final_version_item = None + for v_id, version_item in product_item.version_items.items(): + if v_id == value: + final_version_item = version_item + break + + if final_version_item is None: + return False + if index.data(VERSION_ID_ROLE) == final_version_item.version_id: + return True + item = self.itemFromIndex(index) + self._set_version_data_to_product_item(item, final_version_item) + self.version_changed.emit() + return True + return super(ProductsModel, self).setData(index, value, role) + + def _get_next_color(self): + return next(self._color_iterator) + + def _color_iter(self): + while True: + for color in self.merged_items_colors: + if self._reset_merge_color: + self._reset_merge_color = False + break + yield color + + def _clear(self): + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + self._product_items_by_id = {} + self._reset_merge_color = True + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.object-group", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_model_item(self, group_name): + model_item = self._group_items_by_name.get(group_name) + if model_item is None: + model_item = QtGui.QStandardItem(group_name) + model_item.setData( + self._get_group_icon(), QtCore.Qt.DecorationRole + ) + model_item.setData(0, GROUP_TYPE_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._group_items_by_name[group_name] = model_item + return model_item + + def _get_merged_model_item(self, path, count, hex_color): + model_item = self._merged_items_by_id.get(path) + if model_item is None: + model_item = QtGui.QStandardItem() + model_item.setData(1, GROUP_TYPE_ROLE) + model_item.setData(hex_color, MERGED_COLOR_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._merged_items_by_id[path] = model_item + label = "{} ({})".format(path, count) + model_item.setData(label, QtCore.Qt.DisplayRole) + return model_item + + def _set_version_data_to_product_item(self, model_item, version_item): + """ + + Args: + model_item (QtGui.QStandardItem): Item which should have values + from version item. + version_item (VersionItem): Item from entities model with + information about version. + """ + + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.version, VERSION_NAME_ROLE) + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) + model_item.setData( + version_item.published_time, VERSION_PUBLISH_TIME_ROLE + ) + model_item.setData(version_item.author, VERSION_AUTHOR_ROLE) + model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE) + model_item.setData(version_item.duration, VERSION_DURATION_ROLE) + model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) + model_item.setData(version_item.step, VERSION_STEP_ROLE) + model_item.setData( + version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) + + def _get_product_model_item(self, product_item): + model_item = self._items_by_id.get(product_item.product_id) + versions = list(product_item.version_items.values()) + versions.sort() + last_version = versions[-1] + if model_item is None: + product_id = product_item.product_id + model_item = QtGui.QStandardItem(product_item.product_name) + model_item.setEditable(False) + icon = get_qt_icon(product_item.product_icon) + product_type_icon = get_qt_icon(product_item.product_type_icon) + model_item.setColumnCount(self.columnCount()) + model_item.setData(icon, QtCore.Qt.DecorationRole) + model_item.setData(product_id, PRODUCT_ID_ROLE) + model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) + model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) + model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) + + self._product_items_by_id[product_id] = product_item + self._items_by_id[product_id] = model_item + + model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) + in_scene = 1 if product_item.product_in_scene else 0 + model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) + + self._set_version_data_to_product_item(model_item, last_version) + return model_item + + def get_last_project_name(self): + return self._last_project_name + + def refresh(self, project_name, folder_ids): + self._clear() + + self._last_project_name = project_name + self._last_folder_ids = folder_ids + + product_items = self._controller.get_product_items( + project_name, + folder_ids, + sender=PRODUCTS_MODEL_SENDER_NAME + ) + product_items_by_id = { + product_item.product_id: product_item + for product_item in product_items + } + + # Prepare product groups + product_name_matches_by_group = collections.defaultdict(dict) + for product_item in product_items_by_id.values(): + group_name = None + if self._grouping_enabled: + group_name = product_item.group_name + + product_name = product_item.product_name + group = product_name_matches_by_group[group_name] + if product_name not in group: + group[product_name] = [product_item] + continue + group[product_name].append(product_item) + + group_names = set(product_name_matches_by_group.keys()) + + root_item = self.invisibleRootItem() + new_root_items = [] + merged_paths = set() + for group_name in group_names: + key_parts = [] + if group_name: + key_parts.append(group_name) + + groups = product_name_matches_by_group[group_name] + merged_product_items = {} + top_items = [] + group_product_types = set() + for product_name, product_items in groups.items(): + group_product_types |= {p.product_type for p in product_items} + if len(product_items) == 1: + top_items.append(product_items[0]) + else: + path = "/".join(key_parts + [product_name]) + merged_paths.add(path) + merged_product_items[path] = ( + product_name, + product_items, + ) + + parent_item = None + if group_name: + parent_item = self._get_group_model_item(group_name) + parent_item.setData( + "|".join(group_product_types), PRODUCT_TYPE_ROLE) + + new_items = [] + if parent_item is not None and parent_item.row() < 0: + new_root_items.append(parent_item) + + for product_item in top_items: + item = self._get_product_model_item(product_item) + new_items.append(item) + + for path_info in merged_product_items.values(): + product_name, product_items = path_info + (merged_color_hex, merged_color_qt) = self._get_next_color() + merged_color = qtawesome.icon( + "fa.circle", color=merged_color_qt) + merged_item = self._get_merged_model_item( + product_name, len(product_items), merged_color_hex) + merged_item.setData(merged_color, QtCore.Qt.DecorationRole) + new_items.append(merged_item) + + merged_product_types = set() + new_merged_items = [] + for product_item in product_items: + item = self._get_product_model_item(product_item) + new_merged_items.append(item) + merged_product_types.add(product_item.product_type) + + merged_item.setData( + "|".join(merged_product_types), PRODUCT_TYPE_ROLE) + if new_merged_items: + merged_item.appendRows(new_merged_items) + + if not new_items: + continue + + if parent_item is None: + new_root_items.extend(new_items) + else: + parent_item.appendRows(new_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + self.refreshed.emit() + # --------------------------------- + # This implementation does not call '_clear' at the start + # but is more complex and probably slower + # --------------------------------- + # def _remove_items(self, items): + # if not items: + # return + # root_item = self.invisibleRootItem() + # for item in items: + # row = item.row() + # if row < 0: + # continue + # parent = item.parent() + # if parent is None: + # parent = root_item + # parent.removeRow(row) + # + # def _remove_group_items(self, group_names): + # group_items = [ + # self._group_items_by_name.pop(group_name) + # for group_name in group_names + # ] + # self._remove_items(group_items) + # + # def _remove_merged_items(self, paths): + # merged_items = [ + # self._merged_items_by_id.pop(path) + # for path in paths + # ] + # self._remove_items(merged_items) + # + # def _remove_product_items(self, product_ids): + # product_items = [] + # for product_id in product_ids: + # self._product_items_by_id.pop(product_id) + # product_items.append(self._items_by_id.pop(product_id)) + # self._remove_items(product_items) + # + # def _add_to_new_items(self, item, parent_item, new_items, root_item): + # if item.row() < 0: + # new_items.append(item) + # else: + # item_parent = item.parent() + # if item_parent is not parent_item: + # if item_parent is None: + # item_parent = root_item + # item_parent.takeRow(item.row()) + # new_items.append(item) + + # def refresh(self, project_name, folder_ids): + # product_items = self._controller.get_product_items( + # project_name, + # folder_ids, + # sender=PRODUCTS_MODEL_SENDER_NAME + # ) + # product_items_by_id = { + # product_item.product_id: product_item + # for product_item in product_items + # } + # # Remove product items that are not available + # product_ids_to_remove = ( + # set(self._items_by_id.keys()) - set(product_items_by_id.keys()) + # ) + # self._remove_product_items(product_ids_to_remove) + # + # # Prepare product groups + # product_name_matches_by_group = collections.defaultdict(dict) + # for product_item in product_items_by_id.values(): + # group_name = None + # if self._grouping_enabled: + # group_name = product_item.group_name + # + # product_name = product_item.product_name + # group = product_name_matches_by_group[group_name] + # if product_name not in group: + # group[product_name] = [product_item] + # continue + # group[product_name].append(product_item) + # + # group_names = set(product_name_matches_by_group.keys()) + # + # root_item = self.invisibleRootItem() + # new_root_items = [] + # merged_paths = set() + # for group_name in group_names: + # key_parts = [] + # if group_name: + # key_parts.append(group_name) + # + # groups = product_name_matches_by_group[group_name] + # merged_product_items = {} + # top_items = [] + # for product_name, product_items in groups.items(): + # if len(product_items) == 1: + # top_items.append(product_items[0]) + # else: + # path = "/".join(key_parts + [product_name]) + # merged_paths.add(path) + # merged_product_items[path] = product_items + # + # parent_item = None + # if group_name: + # parent_item = self._get_group_model_item(group_name) + # + # new_items = [] + # if parent_item is not None and parent_item.row() < 0: + # new_root_items.append(parent_item) + # + # for product_item in top_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, parent_item, new_items, root_item + # ) + # + # for path, product_items in merged_product_items.items(): + # merged_item = self._get_merged_model_item(path) + # self._add_to_new_items( + # merged_item, parent_item, new_items, root_item + # ) + # + # new_merged_items = [] + # for product_item in product_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, merged_item, new_merged_items, root_item + # ) + # + # if new_merged_items: + # merged_item.appendRows(new_merged_items) + # + # if not new_items: + # continue + # + # if parent_item is not None: + # parent_item.appendRows(new_items) + # continue + # + # new_root_items.extend(new_items) + # + # root_item.appendRows(new_root_items) + # + # merged_item_ids_to_remove = ( + # set(self._merged_items_by_id.keys()) - merged_paths + # ) + # group_names_to_remove = ( + # set(self._group_items_by_name.keys()) - set(group_names) + # ) + # self._remove_merged_items(merged_item_ids_to_remove) + # self._remove_group_items(group_names_to_remove) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py new file mode 100644 index 0000000000..cfc18431a6 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -0,0 +1,400 @@ +import collections + +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .products_model import ( + ProductsModel, + PRODUCTS_MODEL_SENDER_NAME, + PRODUCT_TYPE_ROLE, + GROUP_TYPE_ROLE, + MERGED_COLOR_ROLE, + FOLDER_ID_ROLE, + PRODUCT_ID_ROLE, + VERSION_ID_ROLE, + VERSION_THUMBNAIL_ID_ROLE, +) +from .products_delegates import VersionDelegate, LoadedInSceneDelegate +from .actions_utils import show_actions_menu + + +class ProductsProxyModel(RecursiveSortFilterProxyModel): + def __init__(self, parent=None): + super(ProductsProxyModel, self).__init__(parent) + + self._product_type_filters = {} + self._ascending_sort = True + + def set_product_type_filters(self, product_type_filters): + self._product_type_filters = product_type_filters + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + index = source_model.index(source_row, 0, source_parent) + product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) + product_types = [] + if product_types_s: + product_types = product_types_s.split("|") + + for product_type in product_types: + if not self._product_type_filters.get(product_type, True): + return False + return super(ProductsProxyModel, self).filterAcceptsRow( + source_row, source_parent) + + def lessThan(self, left, right): + l_model = left.model() + r_model = right.model() + left_group_type = l_model.data(left, GROUP_TYPE_ROLE) + right_group_type = r_model.data(right, GROUP_TYPE_ROLE) + # Groups are always on top, merged product types are below + # and items without group at the bottom + # QUESTION Do we need to do it this way? + if left_group_type != right_group_type: + if left_group_type is None: + output = False + elif right_group_type is None: + output = True + else: + output = left_group_type < right_group_type + if not self._ascending_sort: + output = not output + return output + return super(ProductsProxyModel, self).lessThan(left, right) + + def sort(self, column, order=None): + if order is None: + order = QtCore.Qt.AscendingOrder + self._ascending_sort = order == QtCore.Qt.AscendingOrder + super(ProductsProxyModel, self).sort(column, order) + + +class ProductsWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() + merged_products_selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal() + version_changed = QtCore.Signal() + default_widths = ( + 200, # Product name + 90, # Product type + 130, # Folder label + 60, # Version + 125, # Time + 75, # Author + 75, # Frames + 60, # Duration + 55, # Handles + 10, # Step + 25, # Loaded in scene + 65, # Site info (maybe?) + ) + + def __init__(self, controller, parent): + super(ProductsWidget, self).__init__(parent) + + self._controller = controller + + products_view = DeselectableTreeView(self) + # TODO - define custom object name in style + products_view.setObjectName("SubsetView") + products_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + products_view.setAllColumnsShowFocus(True) + # TODO - add context menu + products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + products_view.setSortingEnabled(True) + # Sort by product type + products_view.sortByColumn(1, QtCore.Qt.AscendingOrder) + products_view.setAlternatingRowColors(True) + + products_model = ProductsModel(controller) + products_proxy_model = ProductsProxyModel() + products_proxy_model.setSourceModel(products_model) + + products_view.setModel(products_proxy_model) + + for idx, width in enumerate(self.default_widths): + products_view.setColumnWidth(idx, width) + + version_delegate = VersionDelegate() + products_view.setItemDelegateForColumn( + products_model.version_col, version_delegate) + + time_delegate = PrettyTimeDelegate() + products_view.setItemDelegateForColumn( + products_model.published_time_col, time_delegate) + + in_scene_delegate = LoadedInSceneDelegate() + products_view.setItemDelegateForColumn( + products_model.in_scene_col, in_scene_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(products_view, 1) + + products_proxy_model.rowsInserted.connect(self._on_rows_inserted) + products_proxy_model.rowsMoved.connect(self._on_rows_moved) + products_model.refreshed.connect(self._on_refresh) + products_view.customContextMenuRequested.connect( + self._on_context_menu) + products_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + products_model.version_changed.connect(self._on_version_change) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_change, + ) + controller.register_event_callback( + "products.refresh.finished", + self._on_products_refresh_finished + ) + controller.register_event_callback( + "products.group.changed", + self._on_group_changed + ) + + self._products_view = products_view + self._products_model = products_model + self._products_proxy_model = products_proxy_model + + self._version_delegate = version_delegate + self._time_delegate = time_delegate + + self._selected_project_name = None + self._selected_folder_ids = set() + + self._selected_merged_products = [] + self._selected_versions_info = [] + + # Set initial state of widget + # - Hide folders column + self._update_folders_label_visible() + # - Hide in scene column if is not supported (this won't change) + products_view.setColumnHidden( + products_model.in_scene_col, + not controller.is_loaded_products_supported() + ) + + def set_name_filer(self, name): + """Set filter of product name. + + Args: + name (str): The string filter. + """ + + self._products_proxy_model.setFilterFixedString(name) + + def set_product_type_filter(self, product_type_filters): + """ + + Args: + product_type_filters (dict[str, bool]): The filter of product + types. + """ + + self._products_proxy_model.set_product_type_filters( + product_type_filters + ) + + def set_enable_grouping(self, enable_grouping): + self._products_model.set_enable_grouping(enable_grouping) + + def get_selected_merged_products(self): + return self._selected_merged_products + + def get_selected_version_info(self): + return self._selected_versions_info + + def refresh(self): + self._refresh_model() + + def _fill_version_editor(self): + model = self._products_proxy_model + index_queue = collections.deque() + for row in range(model.rowCount()): + index_queue.append((row, None)) + + version_col = self._products_model.version_col + while index_queue: + (row, parent_index) = index_queue.popleft() + args = [row, 0] + if parent_index is not None: + args.append(parent_index) + index = model.index(*args) + rows = model.rowCount(index) + for row in range(rows): + index_queue.append((row, index)) + + product_id = model.data(index, PRODUCT_ID_ROLE) + if product_id is not None: + args[1] = version_col + v_index = model.index(*args) + self._products_view.openPersistentEditor(v_index) + + def _on_refresh(self): + self._fill_version_editor() + self.refreshed.emit() + + def _on_rows_inserted(self): + self._fill_version_editor() + + def _on_rows_moved(self): + self._fill_version_editor() + + def _refresh_model(self): + self._products_model.refresh( + self._selected_project_name, + self._selected_folder_ids + ) + + def _on_context_menu(self, point): + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + project_name = self._products_model.get_last_project_name() + + version_ids = set() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + while indexes_queue: + index = indexes_queue.popleft() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + version_id = model.data(index, VERSION_ID_ROLE) + if version_id is not None: + version_ids.add(version_id) + + action_items = self._controller.get_versions_action_items( + project_name, version_ids) + + # Prepare global point where to show the menu + global_point = self._products_view.mapToGlobal(point) + + result = show_actions_menu( + action_items, + global_point, + len(version_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) + + def _on_selection_change(self): + selected_merged_products = [] + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + # Helper for 'version_items' to avoid duplicated items + all_product_ids = set() + selected_version_ids = set() + # Version items contains information about selected version items + selected_versions_info = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + product_id = model.data(index, PRODUCT_ID_ROLE) + # Skip duplicates - when group and item are selected the item + # would be in the loop multiple times + if product_id in all_product_ids: + continue + + all_product_ids.add(product_id) + + version_id = model.data(index, VERSION_ID_ROLE) + selected_version_ids.add(version_id) + + thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE) + selected_versions_info.append({ + "folder_id": model.data(index, FOLDER_ID_ROLE), + "product_id": product_id, + "version_id": version_id, + "thumbnail_id": thumbnail_id, + }) + continue + + if group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + continue + + if group_type != 1: + continue + + item_folder_ids = set() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + folder_id = model.data(child_index, FOLDER_ID_ROLE) + item_folder_ids.add(folder_id) + + if not item_folder_ids: + continue + + hex_color = model.data(index, MERGED_COLOR_ROLE) + item_data = { + "color": hex_color, + "folder_ids": item_folder_ids + } + selected_merged_products.append(item_data) + + prev_selected_merged_products = self._selected_merged_products + self._selected_merged_products = selected_merged_products + self._selected_versions_info = selected_versions_info + + if selected_merged_products != prev_selected_merged_products: + self.merged_products_selection_changed.emit() + self.selection_changed.emit() + self._controller.set_selected_versions(selected_version_ids) + + def _on_version_change(self): + self._on_selection_change() + + def _on_folders_selection_change(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_ids = event["folder_ids"] + self._refresh_model() + self._update_folders_label_visible() + + def _update_folders_label_visible(self): + folders_label_hidden = len(self._selected_folder_ids) <= 1 + self._products_view.setColumnHidden( + self._products_model.folders_label_col, + folders_label_hidden + ) + + def _on_products_refresh_finished(self, event): + if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: + self._refresh_model() + + def _on_group_changed(self, event): + if event["project_name"] != self._selected_project_name: + return + folder_ids = event["folder_ids"] + if not set(folder_ids).intersection(set(self._selected_folder_ids)): + return + self.refresh() diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py new file mode 100644 index 0000000000..7de582e629 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -0,0 +1,338 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore +import qtawesome + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon +from openpype.tools.utils import DeselectableTreeView + +from .actions_utils import show_actions_menu + +REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1 +REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4 +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class RepresentationsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + colums_info = [ + ("Name", 120), + ("Product name", 125), + ("Folder", 125), + # ("Active site", 85), + # ("Remote site", 85) + ] + column_labels = [label for label, _ in colums_info] + column_widths = [width for _, width in colums_info] + folder_column = column_labels.index("Product name") + + def __init__(self, controller): + super(RepresentationsModel, self).__init__() + + self.setColumnCount(len(self.column_labels)) + + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_version_change + ) + self._selected_project_name = None + self._selected_version_ids = None + + self._group_icon = None + + self._items_by_id = {} + self._groups_items_by_name = {} + + self._controller = controller + + def refresh(self): + repre_items = self._controller.get_representation_items( + self._selected_project_name, self._selected_version_ids + ) + self._fill_items(repre_items) + self.refreshed.emit() + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + if role == QtCore.Qt.DecorationRole: + return None + + if role == QtCore.Qt.DisplayRole: + if col == 1: + role = PRODUCT_NAME_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + index = self.index(index.row(), 0, index.parent()) + return super(RepresentationsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if role is None: + role = QtCore.Qt.EditRole + return super(RepresentationsModel, self).setData(index, value, role) + + def _clear_items(self): + self._items_by_id = {} + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def _get_repre_item(self, repre_item): + repre_id = repre_item.representation_id + repre_name = repre_item.representation_name + repre_icon = repre_item.representation_icon + item = self._items_by_id.get(repre_id) + is_new_item = False + if item is None: + is_new_item = True + item = QtGui.QStandardItem() + self._items_by_id[repre_id] = item + item.setColumnCount(self.columnCount()) + item.setEditable(False) + + icon = get_qt_icon(repre_icon) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(repre_name, REPRESENTAION_NAME_ROLE) + item.setData(repre_id, REPRESENTATION_ID_ROLE) + item.setData(repre_item.product_name, PRODUCT_NAME_ROLE) + item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE) + return is_new_item, item + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.folder", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_item(self, repre_name): + item = self._groups_items_by_name.get(repre_name) + if item is not None: + return False, item + + # TODO add color + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole) + item.setData(0, GROUP_TYPE_ROLE) + item.setEditable(False) + self._groups_items_by_name[repre_name] = item + return True, item + + def _fill_items(self, repre_items): + items_to_remove = set(self._items_by_id.keys()) + repre_items_by_name = collections.defaultdict(list) + for repre_item in repre_items: + items_to_remove.discard(repre_item.representation_id) + repre_name = repre_item.representation_name + repre_items_by_name[repre_name].append(repre_item) + + root_item = self.invisibleRootItem() + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + group_names = set() + new_root_items = [] + for repre_name, repre_name_items in repre_items_by_name.items(): + group_item = None + parent_is_group = False + if len(repre_name_items) > 1: + group_names.add(repre_name) + is_new_group, group_item = self._get_group_item(repre_name) + if is_new_group: + new_root_items.append(group_item) + parent_is_group = True + + new_group_items = [] + for repre_item in repre_name_items: + is_new_item, item = self._get_repre_item(repre_item) + item_parent = item.parent() + if item_parent is None: + item_parent = root_item + + if not is_new_item: + if parent_is_group: + if item_parent is group_item: + continue + elif item_parent is root_item: + continue + item_parent.takeRow(item.row()) + is_new_item = True + + if is_new_item: + new_group_items.append(item) + + if not new_group_items: + continue + + if group_item is not None: + group_item.appendRows(new_group_items) + else: + new_root_items.extend(new_group_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + for group_name in set(self._groups_items_by_name) - group_names: + item = self._groups_items_by_name.pop(group_name) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _on_version_change(self, event): + self._selected_version_ids = event["version_ids"] + self.refresh() + + +class RepresentationsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(RepresentationsWidget, self).__init__(parent) + + repre_view = DeselectableTreeView(self) + repre_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + repre_view.setSortingEnabled(True) + repre_view.setAlternatingRowColors(True) + + repre_model = RepresentationsModel(controller) + repre_proxy_model = QtCore.QSortFilterProxyModel() + repre_proxy_model.setSourceModel(repre_model) + repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + repre_view.setModel(repre_proxy_model) + + for idx, width in enumerate(repre_model.column_widths): + repre_view.setColumnWidth(idx, width) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(repre_view, 1) + + repre_view.customContextMenuRequested.connect( + self._on_context_menu) + repre_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + repre_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folder_change + ) + + self._controller = controller + self._selected_project_name = None + self._selected_multiple_folders = None + + self._repre_view = repre_view + self._repre_model = repre_model + self._repre_proxy_model = repre_proxy_model + + self._set_multiple_folders_selected(False) + + def refresh(self): + self._repre_model.refresh() + + def _on_folder_change(self, event): + self._set_multiple_folders_selected(len(event["folder_ids"]) > 1) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _set_multiple_folders_selected(self, selected_multiple_folders): + if selected_multiple_folders == self._selected_multiple_folders: + return + self._selected_multiple_folders = selected_multiple_folders + self._repre_view.setColumnHidden( + self._repre_model.folder_column, + not self._selected_multiple_folders + ) + + def _on_model_refresh(self): + self._repre_proxy_model.sort(0) + + def _get_selected_repre_indexes(self): + selection_model = self._repre_view.selectionModel() + model = self._repre_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + selected_indexes = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + selected_indexes.append(index) + + elif group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + return selected_indexes + + def _get_selected_repre_ids(self): + repre_ids = { + index.data(REPRESENTATION_ID_ROLE) + for index in self._get_selected_repre_indexes() + } + repre_ids.discard(None) + return repre_ids + + def _on_selection_change(self): + selected_repre_ids = self._get_selected_repre_ids() + self._controller.set_selected_representations(selected_repre_ids) + + def _on_context_menu(self, point): + repre_ids = self._get_selected_repre_ids() + action_items = self._controller.get_representations_action_items( + self._selected_project_name, repre_ids + ) + global_point = self._repre_view.mapToGlobal(point) + result = show_actions_menu( + action_items, + global_point, + len(repre_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py new file mode 100644 index 0000000000..ca17e4b9fd --- /dev/null +++ b/openpype/tools/ayon_loader/ui/window.py @@ -0,0 +1,511 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.resources import get_openpype_icon_filepath +from openpype.style import load_stylesheet +from openpype.tools.utils import ( + PlaceholderLineEdit, + ErrorMessageBox, + ThumbnailPainterWidget, + RefreshButton, + GoToCurrentButton, +) +from openpype.tools.utils.lib import center_window +from openpype.tools.ayon_utils.widgets import ProjectsCombobox +from openpype.tools.ayon_loader.control import LoaderController + +from .folders_widget import LoaderFoldersWidget +from .products_widget import ProductsWidget +from .product_types_widget import ProductTypesView +from .product_group_dialog import ProductGroupDialog +from .info_widget import InfoWidget +from .repres_widget import RepresentationsWidget + + +class LoadErrorMessageBox(ErrorMessageBox): + def __init__(self, messages, parent=None): + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, product, version in self._messages: + report_message = ( + "During load error happened on Product: \"{product}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + product=product, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + + def _create_content(self, content_layout): + item_name_template = ( + "Product: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb_text, repre, product, version in self._messages: + line = self._create_line() + content_layout.addWidget(line) + + item_name = item_name_template.format(product, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) + + if tb_text: + line = self._create_line() + tb_widget = self._create_traceback_widget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) + + +class RefreshHandler: + def __init__(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + @property + def project_refreshed(self): + return self._products_refreshed + + @property + def folders_refreshed(self): + return self._folders_refreshed + + @property + def products_refreshed(self): + return self._products_refreshed + + def reset(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + def set_project_refreshed(self): + self._project_refreshed = True + + def set_folders_refreshed(self): + self._folders_refreshed = True + + def set_products_refreshed(self): + self._products_refreshed = True + + +class LoaderWindow(QtWidgets.QWidget): + def __init__(self, controller=None, parent=None): + super(LoaderWindow, self).__init__(parent) + + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("AYON Loader") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) + + if controller is None: + controller = LoaderController() + + main_splitter = QtWidgets.QSplitter(self) + + context_splitter = QtWidgets.QSplitter(main_splitter) + context_splitter.setOrientation(QtCore.Qt.Vertical) + + # Context selection widget + context_widget = QtWidgets.QWidget(context_splitter) + + context_top_widget = QtWidgets.QWidget(context_widget) + projects_combobox = ProjectsCombobox( + controller, + context_top_widget, + handle_expected_selection=True + ) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_libraries_separator_visible(True) + projects_combobox.set_standard_filter_enabled( + controller.is_standard_projects_filter_enabled() + ) + + go_to_current_btn = GoToCurrentButton(context_top_widget) + refresh_btn = RefreshButton(context_top_widget) + + context_top_layout = QtWidgets.QHBoxLayout(context_top_widget) + context_top_layout.setContentsMargins(0, 0, 0, 0,) + context_top_layout.addWidget(projects_combobox, 1) + context_top_layout.addWidget(go_to_current_btn, 0) + context_top_layout.addWidget(refresh_btn, 0) + + folders_filter_input = PlaceholderLineEdit(context_widget) + folders_filter_input.setPlaceholderText("Folder name filter...") + + folders_widget = LoaderFoldersWidget(controller, context_widget) + + product_types_widget = ProductTypesView(controller, context_splitter) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(context_top_widget, 0) + context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(folders_widget, 1) + + context_splitter.addWidget(context_widget) + context_splitter.addWidget(product_types_widget) + context_splitter.setStretchFactor(0, 65) + context_splitter.setStretchFactor(1, 35) + + # Product + version selection item + products_wrap_widget = QtWidgets.QWidget(main_splitter) + + products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) + + products_filter_input = PlaceholderLineEdit(products_inputs_widget) + products_filter_input.setPlaceholderText("Product name filter...") + product_group_checkbox = QtWidgets.QCheckBox( + "Enable grouping", products_inputs_widget) + product_group_checkbox.setChecked(True) + + products_widget = ProductsWidget(controller, products_wrap_widget) + + products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) + products_inputs_layout.setContentsMargins(0, 0, 0, 0) + products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_group_checkbox, 0) + + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) + products_wrap_layout.setContentsMargins(0, 0, 0, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) + products_wrap_layout.addWidget(products_widget, 1) + + right_panel_splitter = QtWidgets.QSplitter(main_splitter) + right_panel_splitter.setOrientation(QtCore.Qt.Vertical) + + thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter) + thumbnails_widget.set_use_checkboard(False) + + info_widget = InfoWidget(controller, right_panel_splitter) + + repre_widget = RepresentationsWidget(controller, right_panel_splitter) + + right_panel_splitter.addWidget(thumbnails_widget) + right_panel_splitter.addWidget(info_widget) + right_panel_splitter.addWidget(repre_widget) + + right_panel_splitter.setStretchFactor(0, 1) + right_panel_splitter.setStretchFactor(1, 1) + right_panel_splitter.setStretchFactor(2, 2) + + main_splitter.addWidget(context_splitter) + main_splitter.addWidget(products_wrap_widget) + main_splitter.addWidget(right_panel_splitter) + + main_splitter.setStretchFactor(0, 4) + main_splitter.setStretchFactor(1, 6) + main_splitter.setStretchFactor(2, 1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(main_splitter) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + show_timer.timeout.connect(self._on_show_timer) + + projects_combobox.refreshed.connect(self._on_projects_refresh) + folders_widget.refreshed.connect(self._on_folders_refresh) + products_widget.refreshed.connect(self._on_products_refresh) + folders_filter_input.textChanged.connect( + self._on_folder_filter_change + ) + product_types_widget.filter_changed.connect( + self._on_product_type_filter_change + ) + products_filter_input.textChanged.connect( + self._on_product_filter_change + ) + product_group_checkbox.stateChanged.connect( + self._on_product_group_change + ) + products_widget.merged_products_selection_changed.connect( + self._on_merged_products_selection_change + ) + products_widget.selection_changed.connect( + self._on_products_selection_change + ) + go_to_current_btn.clicked.connect( + self._on_go_to_current_context_click + ) + refresh_btn.clicked.connect( + self._on_refresh_click + ) + controller.register_event_callback( + "load.finished", + self._on_load_finished, + ) + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_changed, + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_versions_selection_changed, + ) + controller.register_event_callback( + "controller.reset.started", + self._on_controller_reset_start, + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset_finish, + ) + + self._group_dialog = ProductGroupDialog(controller, self) + + self._main_splitter = main_splitter + + self._go_to_current_btn = go_to_current_btn + self._refresh_btn = refresh_btn + self._projects_combobox = projects_combobox + + self._folders_filter_input = folders_filter_input + self._folders_widget = folders_widget + + self._product_types_widget = product_types_widget + + self._products_filter_input = products_filter_input + self._product_group_checkbox = product_group_checkbox + self._products_widget = products_widget + + self._right_panel_splitter = right_panel_splitter + self._thumbnails_widget = thumbnails_widget + self._info_widget = info_widget + self._repre_widget = repre_widget + + self._controller = controller + self._refresh_handler = RefreshHandler() + self._first_show = True + self._reset_on_show = True + self._show_counter = 0 + self._show_timer = show_timer + self._selected_project_name = None + self._selected_folder_ids = set() + self._selected_version_ids = set() + + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def refresh(self): + self._controller.reset() + + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + + if self._first_show: + self._on_first_show() + + self._show_timer.start() + + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_G + and not event.isAutoRepeat() + ): + self._show_group_dialog() + event.setAccepted(True) + return + + super(LoaderWindow, self).keyPressEvent(event) + + def _on_first_show(self): + self._first_show = False + # width, height = 1800, 900 + width, height = 1500, 750 + + self.resize(width, height) + + mid_width = int(width / 1.8) + sides_width = int((width - mid_width) * 0.5) + self._main_splitter.setSizes( + [sides_width, mid_width, sides_width] + ) + + thumbnail_height = int(height / 3.6) + info_height = int((height - thumbnail_height) * 0.5) + self._right_panel_splitter.setSizes( + [thumbnail_height, info_height, info_height] + ) + self.setStyleSheet(load_stylesheet()) + center_window(self) + + def _on_show_timer(self): + if self._show_counter < 2: + self._show_counter += 1 + return + + self._show_counter = 0 + self._show_timer.stop() + + if self._reset_on_show: + self._reset_on_show = False + self._controller.reset() + + def _show_group_dialog(self): + project_name = self._projects_combobox.get_current_project_name() + if not project_name: + return + + product_ids = { + i["product_id"] + for i in self._products_widget.get_selected_version_info() + } + if not product_ids: + return + + self._group_dialog.set_product_ids(project_name, product_ids) + self._group_dialog.show() + + def _on_folder_filter_change(self, text): + self._folders_widget.set_name_filer(text) + + def _on_product_group_change(self): + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def _on_product_filter_change(self, text): + self._products_widget.set_name_filer(text) + + def _on_product_type_filter_change(self): + self._products_widget.set_product_type_filter( + self._product_types_widget.get_filter_info() + ) + + def _on_merged_products_selection_change(self): + items = self._products_widget.get_selected_merged_products() + self._folders_widget.set_merged_products_selection(items) + + def _on_products_selection_change(self): + items = self._products_widget.get_selected_version_info() + self._info_widget.set_selected_version_info( + self._projects_combobox.get_current_project_name(), + items + ) + + def _on_go_to_current_context_click(self): + context = self._controller.get_current_context() + self._controller.set_expected_selection( + context["project_name"], + context["folder_id"], + ) + + def _on_refresh_click(self): + self._controller.reset() + + def _on_controller_reset_start(self): + self._refresh_handler.reset() + + def _on_controller_reset_finish(self): + context = self._controller.get_current_context() + project_name = context["project_name"] + self._go_to_current_btn.setVisible(bool(project_name)) + self._projects_combobox.set_current_context_project(project_name) + if not self._refresh_handler.project_refreshed: + self._projects_combobox.refresh() + + def _on_load_finished(self, event): + error_info = event["error_info"] + if not error_info: + return + + box = LoadErrorMessageBox(error_info, self) + box.show() + + def _on_project_selection_changed(self, event): + self._selected_project_name = event["project_name"] + + def _on_folders_selection_changed(self, event): + self._selected_folder_ids = set(event["folder_ids"]) + self._update_thumbnails() + + def _on_versions_selection_changed(self, event): + self._selected_version_ids = set(event["version_ids"]) + self._update_thumbnails() + + def _update_thumbnails(self): + project_name = self._selected_project_name + thumbnail_ids = set() + if self._selected_version_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_version_thumbnail_ids( + project_name, + self._selected_version_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + elif self._selected_folder_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_folder_thumbnail_ids( + project_name, + self._selected_folder_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + + thumbnail_ids.discard(None) + + if not thumbnail_ids: + self._thumbnails_widget.set_current_thumbnails(None) + return + + thumbnail_paths = set() + for thumbnail_id in thumbnail_ids: + thumbnail_path = self._controller.get_thumbnail_path( + project_name, thumbnail_id) + thumbnail_paths.add(thumbnail_path) + thumbnail_paths.discard(None) + self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) + + def _on_projects_refresh(self): + self._refresh_handler.set_project_refreshed() + if not self._refresh_handler.folders_refreshed: + self._folders_widget.refresh() + + def _on_folders_refresh(self): + self._refresh_handler.set_folders_refreshed() + if not self._refresh_handler.products_refreshed: + self._products_widget.refresh() + + def _on_products_refresh(self): + self._refresh_handler.set_products_refreshed() diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py index 1434282c5b..69722b5e21 100644 --- a/openpype/tools/ayon_utils/models/__init__.py +++ b/openpype/tools/ayon_utils/models/__init__.py @@ -12,6 +12,7 @@ from .hierarchy import ( HierarchyModel, HIERARCHY_MODEL_SENDER, ) +from .thumbnails import ThumbnailsModel __all__ = ( @@ -26,4 +27,6 @@ __all__ = ( "TaskItem", "HierarchyModel", "HIERARCHY_MODEL_SENDER", + + "ThumbnailsModel", ) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 93f4c48d98..6c30d22f3a 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -29,9 +29,8 @@ class FolderItem: parent_id (Union[str, None]): Parent folder id. If 'None' then project is parent. name (str): Name of folder. - label (str): Folder label. - icon_name (str): Name of icon from font awesome. - icon_color (str): Hex color string that will be used for icon. + label (Union[str, None]): Folder label. + icon (Union[dict[str, Any], None]): Icon definition. """ def __init__( @@ -240,23 +239,65 @@ class HierarchyModel(object): self._refresh_tasks_cache(project_name, folder_id, sender) return task_cache.get_data() + def get_folder_entities(self, project_name, folder_ids): + """Get folder entities by ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Any]: Folder entities by id. + """ + + output = {} + folder_ids = set(folder_ids) + if not project_name or not folder_ids: + return output + + folder_ids_to_query = set() + for folder_id in folder_ids: + cache = self._folders_by_id[project_name][folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + elif folder_id: + folder_ids_to_query.add(folder_id) + else: + output[folder_id] = None + self._query_folder_entities(project_name, folder_ids_to_query) + for folder_id in folder_ids_to_query: + cache = self._folders_by_id[project_name][folder_id] + output[folder_id] = cache.get_data() + return output + def get_folder_entity(self, project_name, folder_id): - cache = self._folders_by_id[project_name][folder_id] - if not cache.is_valid: - entity = None - if folder_id: - entity = ayon_api.get_folder_by_id(project_name, folder_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_folder_entities(project_name, {folder_id}) + return output[folder_id] + + def get_task_entities(self, project_name, task_ids): + output = {} + task_ids = set(task_ids) + if not project_name or not task_ids: + return output + + task_ids_to_query = set() + for task_id in task_ids: + cache = self._tasks_by_id[project_name][task_id] + if cache.is_valid: + output[task_id] = cache.get_data() + elif task_id: + task_ids_to_query.add(task_id) + else: + output[task_id] = None + self._query_task_entities(project_name, task_ids_to_query) + for task_id in task_ids_to_query: + cache = self._tasks_by_id[project_name][task_id] + output[task_id] = cache.get_data() + return output def get_task_entity(self, project_name, task_id): - cache = self._tasks_by_id[project_name][task_id] - if not cache.is_valid: - entity = None - if task_id: - entity = ayon_api.get_task_by_id(project_name, task_id) - cache.update_data(entity) - return cache.get_data() + output = self.get_task_entities(project_name, {task_id}) + return output[task_id] @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): @@ -326,6 +367,25 @@ class HierarchyModel(object): hierachy_queue.extend(item["children"] or []) return folder_items + def _query_folder_entities(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + project_cache = self._folders_by_id[project_name] + folders = ayon_api.get_folders(project_name, folder_ids=folder_ids) + for folder in folders: + folder_id = folder["id"] + project_cache[folder_id].update_data(folder) + + def _query_task_entities(self, project_name, task_ids): + if not project_name or not task_ids: + return + + project_cache = self._tasks_by_id[project_name] + tasks = ayon_api.get_tasks(project_name, task_ids=task_ids) + for task in tasks: + task_id = task["id"] + project_cache[task_id].update_data(task) + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): if folder_id in self._tasks_refreshing: return diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index ae3eeecea4..4ad53fbbfa 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -29,13 +29,14 @@ class ProjectItem: is parent. """ - def __init__(self, name, active, icon=None): + def __init__(self, name, active, is_library, icon=None): self.name = name self.active = active + self.is_library = is_library if icon is None: icon = { "type": "awesome-font", - "name": "fa.map", + "name": "fa.book" if is_library else "fa.map", "color": get_default_entity_icon_color(), } self.icon = icon @@ -50,6 +51,7 @@ class ProjectItem: return { "name": self.name, "active": self.active, + "is_library": self.is_library, "icon": self.icon, } @@ -78,7 +80,7 @@ def _get_project_items_from_entitiy(projects): """ return [ - ProjectItem(project["name"], project["active"]) + ProjectItem(project["name"], project["active"], project["library"]) for project in projects ] @@ -141,5 +143,5 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) def _query_projects(self): - projects = ayon_api.get_projects(fields=["name", "active"]) + projects = ayon_api.get_projects(fields=["name", "active", "library"]) return _get_project_items_from_entitiy(projects) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py new file mode 100644 index 0000000000..40892338df --- /dev/null +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -0,0 +1,118 @@ +import collections + +import ayon_api + +from openpype.client.server.thumbnails import AYONThumbnailCache + +from .cache import NestedCacheItem + + +class ThumbnailsModel: + entity_cache_lifetime = 240 # In seconds + + def __init__(self): + self._thumbnail_cache = AYONThumbnailCache() + self._paths_cache = collections.defaultdict(dict) + self._folders_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + self._versions_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + + def reset(self): + self._paths_cache = collections.defaultdict(dict) + self._folders_cache.reset() + self._versions_cache.reset() + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._get_thumbnail_path(project_name, thumbnail_id) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + project_cache = self._folders_cache[project_name] + output = {} + missing_cache = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + else: + missing_cache.add(folder_id) + self._query_folder_thumbnail_ids(project_name, missing_cache) + for folder_id in missing_cache: + cache = project_cache[folder_id] + output[folder_id] = cache.get_data() + return output + + def get_version_thumbnail_ids(self, project_name, version_ids): + project_cache = self._versions_cache[project_name] + output = {} + missing_cache = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_cache.add(version_id) + self._query_version_thumbnail_ids(project_name, missing_cache) + for version_id in missing_cache: + cache = project_cache[version_id] + output[version_id] = cache.get_data() + return output + + def _get_thumbnail_path(self, project_name, thumbnail_id): + if not thumbnail_id: + return None + + project_cache = self._paths_cache[project_name] + if thumbnail_id in project_cache: + return project_cache[thumbnail_id] + + filepath = self._thumbnail_cache.get_thumbnail_filepath( + project_name, thumbnail_id + ) + if filepath is not None: + project_cache[thumbnail_id] = filepath + return filepath + + # 'ayon_api' had a bug, public function + # 'get_thumbnail_by_id' did not return output of + # 'ServerAPI' method. + con = ayon_api.get_server_api_connection() + result = con.get_thumbnail_by_id(project_name, thumbnail_id) + if result is None: + pass + + elif result.is_valid: + filepath = self._thumbnail_cache.store_thumbnail( + project_name, + thumbnail_id, + result.content, + result.content_type + ) + project_cache[thumbnail_id] = filepath + return filepath + + def _query_folder_thumbnail_ids(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._folders_cache[project_name] + for folder in folders: + project_cache[folder["id"]] = folder["thumbnailId"] + + def _query_version_thumbnail_ids(self, project_name, version_ids): + if not project_name or not version_ids: + return + + versions = ayon_api.get_versions( + project_name, + version_ids=version_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._versions_cache[project_name] + for version in versions: + project_cache[version["id"]] = version["thumbnailId"] diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 59aef98faf..432a249a73 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -8,11 +8,13 @@ from .projects_widget import ( from .folders_widget import ( FoldersWidget, FoldersModel, + FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, TasksModel, + TASKS_MODEL_SENDER_NAME, ) from .utils import ( get_qt_icon, @@ -28,9 +30,11 @@ __all__ = ( "FoldersWidget", "FoldersModel", + "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", "TasksModel", + "TASKS_MODEL_SENDER_NAME", "get_qt_icon", "RefreshThread", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 4f44881081..b57ffb126a 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -9,7 +9,7 @@ from openpype.tools.utils import ( from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_folders_model" +FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 @@ -112,7 +112,7 @@ class FoldersModel(QtGui.QStandardItemModel): project_name, self._controller.get_folder_items, project_name, - SENDER_NAME + FOLDERS_MODEL_SENDER_NAME ) self._current_refresh_thread = thread self._refresh_threads[thread.id] = thread @@ -142,6 +142,21 @@ class FoldersModel(QtGui.QStandardItemModel): self._fill_items(thread.get_result()) + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + icon = get_qt_icon(folder_item.icon) + item.setData(folder_item.entity_id, ITEM_ID_ROLE) + item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + + def _fill_items(self, folder_items_by_id): if not folder_items_by_id: if folder_items_by_id is not None: @@ -195,11 +210,7 @@ class FoldersModel(QtGui.QStandardItemModel): else: is_new = self._parent_id_by_id[item_id] != parent_id - icon = get_qt_icon(folder_item.icon) - item.setData(item_id, ITEM_ID_ROLE) - item.setData(folder_item.name, ITEM_NAME_ROLE) - item.setData(folder_item.label, QtCore.Qt.DisplayRole) - item.setData(icon, QtCore.Qt.DecorationRole) + self._fill_item_data(item, folder_item) if is_new: new_items.append(item) self._items_by_id[item_id] = item @@ -320,7 +331,7 @@ class FoldersWidget(QtWidgets.QWidget): self._folders_model.set_project_name(project_name) def _on_folders_refresh_finished(self, event): - if event["sender"] != SENDER_NAME: + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: self._set_project_name(event["project_name"]) def _on_controller_refresh(self): diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 818d574910..11bb5de51b 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -5,6 +5,9 @@ from .utils import RefreshThread, get_qt_icon PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 +PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 +PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 class ProjectsModel(QtGui.QStandardItemModel): @@ -15,10 +18,23 @@ class ProjectsModel(QtGui.QStandardItemModel): self._controller = controller self._project_items = {} + self._has_libraries = False self._empty_item = None self._empty_item_added = False + self._select_item = None + self._select_item_added = False + self._select_item_visible = None + + self._libraries_sep_item = None + self._libraries_sep_item_added = False + self._libraries_sep_item_visible = False + + self._current_context_project = None + + self._selected_project = None + self._is_refreshing = False self._refresh_thread = None @@ -32,21 +48,63 @@ class ProjectsModel(QtGui.QStandardItemModel): def has_content(self): return len(self._project_items) > 0 + def set_select_item_visible(self, visible): + if self._select_item_visible is visible: + return + self._select_item_visible = visible + + if self._selected_project is None: + self._add_select_item() + + def set_libraries_separator_visible(self, visible): + if self._libraries_sep_item_visible is visible: + return + self._libraries_sep_item_visible = visible + + def set_selected_project(self, project_name): + if not self._select_item_visible: + return + + self._selected_project = project_name + if project_name is None: + self._add_select_item() + else: + self._remove_select_item() + + def set_current_context_project(self, project_name): + if project_name == self._current_context_project: + return + self._unset_current_context_project(self._current_context_project) + self._current_context_project = project_name + self._set_current_context_project(project_name) + + def _set_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(True, PROJECT_IS_CURRENT_ROLE) + + def _unset_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(False, PROJECT_IS_CURRENT_ROLE) + def _add_empty_item(self): + if self._empty_item_added: + return + self._empty_item_added = True item = self._get_empty_item() - if not self._empty_item_added: - root_item = self.invisibleRootItem() - root_item.appendRow(item) - self._empty_item_added = True + root_item = self.invisibleRootItem() + root_item.appendRow(item) def _remove_empty_item(self): if not self._empty_item_added: return - + self._empty_item_added = False root_item = self.invisibleRootItem() item = self._get_empty_item() root_item.takeRow(item.row()) - self._empty_item_added = False def _get_empty_item(self): if self._empty_item is None: @@ -55,6 +113,61 @@ class ProjectsModel(QtGui.QStandardItemModel): self._empty_item = item return self._empty_item + def _get_library_sep_item(self): + if self._libraries_sep_item is not None: + return self._libraries_sep_item + + item = QtGui.QStandardItem() + item.setData("Libraries", QtCore.Qt.DisplayRole) + item.setData(True, LIBRARY_PROJECT_SEPARATOR_ROLE) + item.setFlags(QtCore.Qt.NoItemFlags) + self._libraries_sep_item = item + return item + + def _add_library_sep_item(self): + if ( + not self._libraries_sep_item_visible + or self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = True + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_library_sep_item(self): + if ( + not self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = False + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _add_select_item(self): + if self._select_item_added: + return + self._select_item_added = True + item = self._get_select_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_select_item(self): + if not self._select_item_added: + return + self._select_item_added = False + root_item = self.invisibleRootItem() + item = self._get_select_item() + root_item.takeRow(item.row()) + + def _get_select_item(self): + if self._select_item is None: + item = QtGui.QStandardItem("< Select project >") + item.setEditable(False) + self._select_item = item + return self._select_item + def _refresh(self): if self._is_refreshing: return @@ -80,44 +193,118 @@ class ProjectsModel(QtGui.QStandardItemModel): self.refreshed.emit() def _fill_items(self, project_items): - items_to_remove = set(self._project_items.keys()) + new_project_names = { + project_item.name + for project_item in project_items + } + + # Handle "Select item" visibility + if self._select_item_visible: + # Add select project. if previously selected project is not in + # project items + if self._selected_project not in new_project_names: + self._add_select_item() + else: + self._remove_select_item() + + root_item = self.invisibleRootItem() + + items_to_remove = set(self._project_items.keys()) - new_project_names + for project_name in items_to_remove: + item = self._project_items.pop(project_name) + root_item.takeRow(item.row()) + + has_library_project = False new_items = [] for project_item in project_items: project_name = project_item.name - items_to_remove.discard(project_name) item = self._project_items.get(project_name) + if project_item.is_library: + has_library_project = True if item is None: item = QtGui.QStandardItem() + item.setEditable(False) new_items.append(item) icon = get_qt_icon(project_item.icon) item.setData(project_name, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) + item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + is_current = project_name == self._current_context_project + item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item - root_item = self.invisibleRootItem() + self._set_current_context_project(self._current_context_project) + + self._has_libraries = has_library_project + if new_items: root_item.appendRows(new_items) - for project_name in items_to_remove: - item = self._project_items.pop(project_name) - root_item.removeRow(item.row()) - if self.has_content(): + # Make sure "No projects" item is removed self._remove_empty_item() + if has_library_project: + self._add_library_sep_item() + else: + self._remove_library_sep_item() else: + # Keep only "No projects" item self._add_empty_item() + self._remove_select_item() + self._remove_library_sep_item() class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) self._filter_inactive = True + self._filter_standard = False + self._filter_library = False + self._sort_by_type = True # Disable case sensitivity self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + def _type_sort(self, l_index, r_index): + if not self._sort_by_type: + return None + + l_is_library = l_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = r_index.data(PROJECT_IS_LIBRARY_ROLE) + # Both hare project items + if l_is_library is not None and r_is_library is not None: + if l_is_library is r_is_library: + return None + if l_is_library: + return False + return True + + if l_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if r_is_library is None: + return False + return r_is_library + + if r_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if l_is_library is None: + return True + return l_is_library + return None + def lessThan(self, left_index, right_index): + # Current project always on top + # - make sure this is always first, before any other sorting + # e.g. type sort would move the item lower + if left_index.data(PROJECT_IS_CURRENT_ROLE): + return True + if right_index.data(PROJECT_IS_CURRENT_ROLE): + return False + + # Library separator should be before library projects + result = self._type_sort(left_index, right_index) + if result is not None: + return result + if left_index.data(PROJECT_NAME_ROLE) is None: return True @@ -137,21 +324,43 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) + project_name = index.data(PROJECT_NAME_ROLE) + if project_name is None: + return True + string_pattern = self.filterRegularExpression().pattern() + if string_pattern: + return string_pattern.lower() in project_name.lower() + + # Current project keep always visible + default = super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + if not default: + return default + + # Make sure current project is visible + if index.data(PROJECT_IS_CURRENT_ROLE): + return True + if ( self._filter_inactive and not index.data(PROJECT_IS_ACTIVE_ROLE) ): return False - if string_pattern: - project_name = index.data(PROJECT_IS_ACTIVE_ROLE) - if project_name is not None: - return string_pattern.lower() in project_name.lower() + if ( + self._filter_standard + and not index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False - return super(ProjectSortFilterProxy, self).filterAcceptsRow( - source_row, source_parent - ) + if ( + self._filter_library + and index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False + return True def _custom_index_filter(self, index): return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) @@ -159,14 +368,34 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def is_active_filter_enabled(self): return self._filter_inactive - def set_active_filter_enabled(self, value): - if self._filter_inactive == value: + def set_active_filter_enabled(self, enabled): + if self._filter_inactive == enabled: return - self._filter_inactive = value + self._filter_inactive = enabled self.invalidateFilter() + def set_library_filter_enabled(self, enabled): + if self._filter_library == enabled: + return + self._filter_library = enabled + self.invalidateFilter() + + def set_standard_filter_enabled(self, enabled): + if self._filter_standard == enabled: + return + self._filter_standard = enabled + self.invalidateFilter() + + def set_sort_by_type(self, enabled): + if self._sort_by_type is enabled: + return + self._sort_by_type = enabled + self.invalidate() + class ProjectsCombobox(QtWidgets.QWidget): + refreshed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(ProjectsCombobox, self).__init__(parent) @@ -203,6 +432,7 @@ class ProjectsCombobox(QtWidgets.QWidget): self._controller = controller self._listen_selection_change = True + self._select_item_visible = False self._handle_expected_selection = handle_expected_selection self._expected_selection = None @@ -264,17 +494,56 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + def set_current_context_project(self, project_name): + self._projects_model.set_current_context_project(project_name) + self._projects_proxy_model.invalidateFilter() + + def _update_select_item_visiblity(self, **kwargs): + if not self._select_item_visible: + return + if "project_name" not in kwargs: + project_name = self.get_current_project_name() + else: + project_name = kwargs.get("project_name") + + # Hide the item if a project is selected + self._projects_model.set_selected_project(project_name) + + def set_select_item_visible(self, visible): + self._select_item_visible = visible + self._projects_model.set_select_item_visible(visible) + self._update_select_item_visiblity() + + def set_libraries_separator_visible(self, visible): + self._projects_model.set_libraries_separator_visible(visible) + + def is_active_filter_enabled(self): + return self._projects_proxy_model.is_active_filter_enabled() + + def set_active_filter_enabled(self, enabled): + return self._projects_proxy_model.set_active_filter_enabled(enabled) + + def set_standard_filter_enabled(self, enabled): + return self._projects_proxy_model.set_standard_filter_enabled(enabled) + + def set_library_filter_enabled(self, enabled): + return self._projects_proxy_model.set_library_filter_enabled(enabled) + def _on_current_index_changed(self, idx): if not self._listen_selection_change: return project_name = self._projects_combobox.itemData( idx, PROJECT_NAME_ROLE) + self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) def _on_model_refresh(self): self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() if self._expected_selection: self._set_expected_selection() + self._update_select_item_visiblity() + self.refreshed.emit() def _on_projects_refresh_finished(self, event): if event["sender"] != PROJECTS_MODEL_SENDER: diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index 0af506863a..da745bd810 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -5,7 +5,7 @@ from openpype.tools.utils import DeselectableTreeView from .utils import RefreshThread, get_qt_icon -SENDER_NAME = "qt_tasks_model" +TASKS_MODEL_SENDER_NAME = "qt_tasks_model" ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 @@ -362,7 +362,7 @@ class TasksWidget(QtWidgets.QWidget): # Refresh only if current folder id is the same if ( - event["sender"] == SENDER_NAME + event["sender"] == TASKS_MODEL_SENDER_NAME or event["folder_id"] != self._selected_folder_id ): return diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 018088e916..ed41d93f0d 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -43,6 +43,7 @@ from .overlay_messages import ( MessageOverlayObject, ) from .multiselection_combobox import MultiSelectionComboBox +from .thumbnail_paint_widget import ThumbnailPainterWidget __all__ = ( @@ -90,4 +91,6 @@ __all__ = ( "MessageOverlayObject", "MultiSelectionComboBox", + + "ThumbnailPainterWidget", ) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 2ebc973a47..ca23945339 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -86,12 +86,22 @@ class HostToolsHelper: def get_loader_tool(self, parent): """Create, cache and return loader tool window.""" if self._loader_tool is None: - from openpype.tools.loader import LoaderWindow - host = registered_host() ILoadHost.validate_load_methods(host) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_loader.ui import LoaderWindow + from openpype.tools.ayon_loader import LoaderController - loader_window = LoaderWindow(parent=parent or self._parent) + controller = LoaderController(host=host) + loader_window = LoaderWindow( + controller=controller, + parent=parent or self._parent + ) + + else: + from openpype.tools.loader import LoaderWindow + + loader_window = LoaderWindow(parent=parent or self._parent) self._loader_tool = loader_window return self._loader_tool @@ -109,7 +119,7 @@ class HostToolsHelper: if use_context is None: use_context = False - if use_context: + if not AYON_SERVER_ENABLED and use_context: context = {"asset": get_current_asset_name()} loader_tool.set_context(context, refresh=True) else: @@ -187,6 +197,9 @@ class HostToolsHelper: def get_library_loader_tool(self, parent): """Create, cache and return library loader tool window.""" + if AYON_SERVER_ENABLED: + return self.get_loader_tool(parent) + if self._library_loader_tool is None: from openpype.tools.libraryloader import LibraryLoaderWindow @@ -199,6 +212,9 @@ class HostToolsHelper: def show_library_loader(self, parent=None): """Loader tool for loading representations from library project.""" + if AYON_SERVER_ENABLED: + return self.show_loader(parent) + with qt_app_context(): library_loader_tool = self.get_library_loader_tool(parent) library_loader_tool.show() diff --git a/openpype/tools/utils/images/__init__.py b/openpype/tools/utils/images/__init__.py new file mode 100644 index 0000000000..3f437fcc8c --- /dev/null +++ b/openpype/tools/utils/images/__init__.py @@ -0,0 +1,56 @@ +import os +from qtpy import QtGui + +IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +def get_image_path(filename): + """Get image path from './images'. + + Returns: + Union[str, None]: Path to image file or None if not found. + """ + + path = os.path.join(IMAGES_DIR, filename) + if os.path.exists(path): + return path + return None + + +def get_image(filename): + """Load image from './images' as QImage. + + Returns: + Union[QtGui.QImage, None]: QImage or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(filename): + """Load image from './images' as QPixmap. + + Returns: + Union[QtGui.QPixmap, None]: QPixmap or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(filename): + """Load image from './images' as QIcon. + + Returns: + Union[QtGui.QIcon, None]: QIcon or None if not found. + """ + + pix = get_pixmap(filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/utils/images/thumbnail.png b/openpype/tools/utils/images/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..adea862e5b7169f8279bd398226a2fe6283e4925 GIT binary patch literal 5118 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 100: + width = int(width * 0.6) + height = int(height * 0.6) + + scaled_pix = self._get_default_pix().scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + pix_painter.setRenderHints(render_hints) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + return new_pix + + def _draw_thumbnails(self, thumbnails, pix_width, pix_height): + full_border_width = 2 * self.border_width + + checker_pix = self._paint_tile(pix_width, pix_height) + + backgrounded_images = [] + for src_pix in thumbnails: + scaled_pix = src_pix.scaled( + pix_width - full_border_width, + pix_height - full_border_width, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + pix_painter.setRenderHints(render_hints) + + tiled_rect = QtCore.QRectF( + pos_x, pos_y, scaled_pix.width(), scaled_pix.height() + ) + pix_painter.drawTiledPixmap( + tiled_rect, + checker_pix, + QtCore.QPointF(0.0, 0.0) + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + 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() + rect_height = rect.height() + + pix_x_offset = 0 + pix_y_offset = 0 + expected_height = int( + (rect_width / self.width_ratio) * self.height_ratio + ) + if expected_height > rect_height: + expected_height = rect_height + expected_width = int( + (rect_height / self.height_ratio) * self.width_ratio + ) + pix_x_offset = (rect_width - expected_width) / 2 + else: + expected_width = rect_width + pix_y_offset = (rect_height - expected_height) / 2 + + if self._current_pixes is None: + used_default_pix = True + pixes_to_draw = None + pixes_len = 1 + else: + used_default_pix = False + pixes_to_draw = self._current_pixes + if len(pixes_to_draw) > self.max_thumbnails: + pixes_to_draw = pixes_to_draw[:-self.max_thumbnails] + pixes_len = len(pixes_to_draw) + + width_offset, height_offset = self._get_pix_offset_size( + expected_width, expected_height, pixes_len + ) + pix_width = expected_width - width_offset + pix_height = expected_height - height_offset + + if used_default_pix: + thumbnail_images = [self._paint_default_pix(pix_width, pix_height)] + else: + thumbnail_images = self._draw_thumbnails( + pixes_to_draw, pix_width, pix_height + ) + + if pixes_len == 1: + width_offset_part = 0 + height_offset_part = 0 + else: + width_offset_part = int(float(width_offset) / (pixes_len - 1)) + height_offset_part = int(float(height_offset) / (pixes_len - 1)) + full_width_offset = width_offset + pix_x_offset + + final_pix = QtGui.QPixmap(rect_width, rect_height) + final_pix.fill(QtCore.Qt.transparent) + + bg_pen = QtGui.QPen() + bg_pen.setWidth(self.border_width) + bg_pen.setColor(self._border_color) + + final_painter = QtGui.QPainter() + final_painter.begin(final_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + final_painter.setRenderHints(render_hints) + + final_painter.setBrush(QtGui.QBrush(self._thumbnail_bg_color)) + final_painter.setPen(bg_pen) + final_painter.drawRect(rect) + + for idx, pix in enumerate(thumbnail_images): + x_offset = full_width_offset - (width_offset_part * idx) + y_offset = (height_offset_part * idx) + pix_y_offset + final_painter.drawPixmap(x_offset, y_offset, pix) + + # Draw drop enabled dashes + if used_default_pix: + self._paint_dash_line(final_painter, rect) + + final_painter.end() + + self._cached_pix = final_pix + + def _get_pix_offset_size(self, width, height, image_count): + if image_count == 1: + return 0, 0 + + part_width = width / self.offset_sep + part_height = height / self.offset_sep + return part_width, part_height From dc28a8d3d285b41556fa9185268e1c3df22b8f51 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 22:28:31 +0200 Subject: [PATCH 283/327] adding backward compatibility apply_settings --- .../plugins/publish/validate_asset_context.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 2a7b7a47d5..04592913f3 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Validate if instance asset is the same as context asset.""" from __future__ import absolute_import +from typing_extensions import deprecated import pyblish.api @@ -56,8 +57,20 @@ class ValidateCorrectAssetContext( ] optional = True - # TODO: apply_settigs to maintain backwards compatibility - # with `ValidateCorrectAssetName` + @classmethod + def apply_settings(cls, project_settings): + """Apply the settings from the deprecated + ExtractReviewDataMov plugin for backwards compatibility + """ + nuke_publish = project_settings["nuke"]["publish"] + if "ValidateCorrectAssetName" not in nuke_publish: + return + + deprecated_setting = nuke_publish["ValidateCorrectAssetName"] + cls.enabled = deprecated_setting["enabled"] + cls.optional = deprecated_setting["optional"] + cls.active = deprecated_setting["active"] + def process(self, instance): if not self.is_active(instance.data): return From 63d27aa331f639a2daa9982a41b4d0e3682c8f7a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 22:32:14 +0200 Subject: [PATCH 284/327] updating docstrings --- .../nuke/plugins/publish/validate_asset_context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 04592913f3..3cd8704b76 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Validate if instance asset is the same as context asset.""" from __future__ import absolute_import -from typing_extensions import deprecated import pyblish.api @@ -17,6 +16,7 @@ from openpype.pipeline.publish import ( class SelectInvalidNodesAction(pyblish.api.Action): + """Select invalid nodes.""" label = "Select Failed Node" icon = "briefcase" @@ -45,8 +45,6 @@ class ValidateCorrectAssetContext( so it can be disabled when needed. Checking `asset` and `task` keys. - - Action on this validator will select invalid instances in Outliner. """ order = ValidateContentsOrder label = "Validate asset context" @@ -59,8 +57,7 @@ class ValidateCorrectAssetContext( @classmethod def apply_settings(cls, project_settings): - """Apply the settings from the deprecated - ExtractReviewDataMov plugin for backwards compatibility + """Apply deprecated settings from project settings. """ nuke_publish = project_settings["nuke"]["publish"] if "ValidateCorrectAssetName" not in nuke_publish: @@ -105,6 +102,7 @@ class ValidateCorrectAssetContext( @classmethod def get_invalid(cls, instance, compute=False): + """Get invalid keys from instance data and context data.""" invalid = instance.data.get("invalid_keys", []) if compute: @@ -122,6 +120,7 @@ class ValidateCorrectAssetContext( @classmethod def repair(cls, instance): + """Repair instance data with context data.""" invalid = cls.get_invalid(instance) create_context = instance.context.data["create_context"] @@ -138,6 +137,7 @@ class ValidateCorrectAssetContext( @classmethod def select(cls, instance): + """Select invalid node """ invalid = cls.get_invalid(instance) if not invalid: return From bf6303a90876a5234c335df386d4f4c99da3ec39 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 22:40:04 +0200 Subject: [PATCH 285/327] hound --- openpype/hosts/nuke/plugins/publish/validate_asset_context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 3cd8704b76..3a5678d61d 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -134,7 +134,6 @@ class ValidateCorrectAssetContext( create_context.save_changes() - @classmethod def select(cls, instance): """Select invalid node """ From e7cd31f2dd5521e6bb8d30e908958f6c3706628a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 12 Oct 2023 11:22:53 +0200 Subject: [PATCH 286/327] Extended error message when getting subset name (#5649) * Modified KeyError message Basic KeyError exception was raised which didn't produce enough information. Now it should be more verbose. * Updated exception message * Changed to custom exception Custom exception can be handled in nicer way that default KeyError * Update openpype/pipeline/create/subset_name.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Renamed custom exception * Update openpype/pipeline/create/subset_name.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/pipeline/create/subset_name.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py index 3f0692b46a..00025b19b8 100644 --- a/openpype/pipeline/create/subset_name.py +++ b/openpype/pipeline/create/subset_name.py @@ -14,6 +14,13 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's subset name template is missing key value." + super(TemplateFillError, self).__init__(msg) + + def get_subset_name_template( project_name, family, @@ -112,6 +119,10 @@ def get_subset_name( for project. Settings are queried if not passed. family_filter (Optional[str]): Use different family for subset template filtering. Value of 'family' is used when not passed. + + Raises: + TemplateFillError: If filled template contains placeholder key which is not + collected. """ if not family: @@ -154,4 +165,10 @@ def get_subset_name( for key, value in dynamic_data.items(): fill_pairs[key] = value - return template.format(**prepare_template_data(fill_pairs)) + try: + return template.format(**prepare_template_data(fill_pairs)) + except KeyError as exp: + raise TemplateFillError( + "Value for {} key is missing in template '{}'." + " Available values are {}".format(str(exp), template, fill_pairs) + ) From 38427b5eecb38b6ffdc2494826907fe542c025b6 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 12 Oct 2023 11:07:09 +0100 Subject: [PATCH 287/327] Testing: Inject mongo_url argument earlier (#5706) * Inject mongo_url argument earlier * monkeypatch instead of os.environ --------- Co-authored-by: Petr Kalis --- tests/lib/testing_classes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index e82e438e54..277b332e19 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -105,7 +105,7 @@ class ModuleUnitTest(BaseTest): yield path @pytest.fixture(scope="module") - def env_var(self, monkeypatch_session, download_test_data): + def env_var(self, monkeypatch_session, download_test_data, mongo_url): """Sets temporary env vars from json file.""" env_url = os.path.join(download_test_data, "input", "env_vars", "env_var.json") @@ -129,6 +129,9 @@ class ModuleUnitTest(BaseTest): monkeypatch_session.setenv(key, str(value)) #reset connection to openpype DB with new env var + if mongo_url: + monkeypatch_session.setenv("OPENPYPE_MONGO", mongo_url) + import openpype.settings.lib as sett_lib sett_lib._SETTINGS_HANDLER = None sett_lib._LOCAL_SETTINGS_HANDLER = None @@ -150,8 +153,7 @@ class ModuleUnitTest(BaseTest): request, mongo_url): """Restore prepared MongoDB dumps into selected DB.""" backup_dir = os.path.join(download_test_data, "input", "dumps") - - uri = mongo_url or os.environ.get("OPENPYPE_MONGO") + uri = os.environ.get("OPENPYPE_MONGO") db_handler = DBHandler(uri) db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, overwrite=True, From 5b3c6b8cfde60a9d053b093e1401b089aa6db8e0 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 12 Oct 2023 13:16:14 +0100 Subject: [PATCH 288/327] Update tests/integration/hosts/maya/input/startup/userSetup.py --- tests/integration/hosts/maya/input/startup/userSetup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/hosts/maya/input/startup/userSetup.py b/tests/integration/hosts/maya/input/startup/userSetup.py index 67352af63d..bb73ec7ee0 100644 --- a/tests/integration/hosts/maya/input/startup/userSetup.py +++ b/tests/integration/hosts/maya/input/startup/userSetup.py @@ -19,10 +19,10 @@ def setup_pyblish_logging(): def _run_publish_test_deferred(): try: + setup_pyblish_logging() pyblish.util.publish() finally: cmds.quit(force=True) -cmds.evalDeferred("setup_pyblish_logging()", evaluateNext=True) cmds.evalDeferred("_run_publish_test_deferred()", lowestPriority=True) From 73a88419d07316cf549decf3bb655554692e0d5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:43:09 +0200 Subject: [PATCH 289/327] Chore: AYON query functions arguments (#5752) * fixe get subsets to work as in mongo api * fixe get assets to work as in mongo api --- openpype/client/server/entities.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 3ee62a3172..16223d3d91 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -75,9 +75,9 @@ def _get_subsets( ): fields.add(key) - active = None + active = True if archived: - active = False + active = None for subset in con.get_products( project_name, @@ -196,7 +196,7 @@ def get_assets( active = True if archived: - active = False + active = None con = get_server_api_connection() fields = folder_fields_v3_to_v4(fields, con) From bc4c2e02004eceeafdb74ea68bb5f54cf411e063 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 12 Oct 2023 12:47:03 +0000 Subject: [PATCH 290/327] [Automated] Release --- CHANGELOG.md | 471 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 473 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f14340348..7d5cf2c4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,477 @@ # Changelog +## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2) + +### **🆕 New features** + + +
+Maya: Add MayaPy application. #5705 + +This adds mayapy to the application to be launched from a task. + + +___ + +
+ + +
+Feature: Copy resources when downloading last workfile #4944 + +When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder. + + +___ + +
+ + +
+Blender: Deadline support #5438 + +Add Deadline support for Blender. + + +___ + +
+ + +
+Fusion: implement toggle to use Deadline plugin FusionCmd #5678 + +Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin: + + +___ + +
+ + +
+Loader tool: Refactor loader tool (for AYON) #5729 + +Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: implement matchmove publishing #5445 + +Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes. + + +___ + +
+ + +
+Maya: Add optional Fbx extractors in Rig and Animation family #5589 + +This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets. + + +___ + +
+ + +
+Maya: Optional Resolution Validator for Render #5693 + +Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db. + + +___ + +
+ + +
+Use host's node uniqueness for instance id in new publisher #5490 + +Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value. + + +___ + +
+ + +
+Max: Implementation of OCIO configuration #5499 + +Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024 + + +___ + +
+ + +
+Nuke: Multiple format supports for ExtractReviewDataMov #5623 + +This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort. + + +___ + +
+ + +
+Bugfix: houdini switching context doesnt update variables #5651 + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand. + + +___ + +
+ + +
+Publisher: Fix report maker memory leak + optimize lookups using set #5667 + +Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds. + + +___ + +
+ + +
+Add openpype_mongo command flag for testing. #5676 + +Instead of changing the environment, this command flag allows for changing the database. + + +___ + +
+ + +
+Nuke: minor docstring and code tweaks for ExtractReviewMov #5695 + +Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623 + + +___ + +
+ + +
+AYON: Small settings fixes #5699 + +Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings. + + +___ + +
+ + +
+Blender: Alembic Animation loader #5711 + +Implemented loading Alembic Animations in Blender. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Missing "data" field and enabling of audio #5618 + +When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline. + + +___ + +
+ + +
+Maya: Bug in validate Plug-in Path Attribute #5687 + +Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin. + + +___ + +
+ + +
+General: Avoid fallback if value is 0 for handle start/end #5652 + +There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong. + + +___ + +
+ + +
+Fusion: added missing env vars to Deadline submission #5659 + +Environment variables discerning type of job was missing. Without this injection of environment variables won't start. + + +___ + +
+ + +
+Nuke: workfile version synchronization settings fixed #5662 + +Settings for synchronizing workfile version to published products is fixed. + + +___ + +
+ + +
+AYON Workfiles Tool: Open workfile changes context #5671 + +Change context when workfile is opened. + + +___ + +
+ + +
+Blender: Fix remove/update in new layout instance #5679 + +Fixes an error that occurs when removing or updating an asset in a new layout instance. + + +___ + +
+ + +
+AYON Launcher tool: Fix refresh btn #5685 + +Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings. + + +___ + +
+ + +
+Deadline: handle all valid paths in RenderExecutable #5694 + +This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location. + + +___ + +
+ + +
+AYON Launcher tool: Fix skip last workfile boolean #5700 + +Skip last workfile boolean works as expected. + + +___ + +
+ + +
+Chore: Explore here action can work without task #5703 + +Explore here action does not crash when task is not selected, and change error message a little. + + +___ + +
+ + +
+Testing: Inject mongo_url argument earlier #5706 + +Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution. + + +___ + +
+ + +
+Blender: Add support to auto-install PySide2 in blender 4 #5723 + +Change version regex to support blender 4 subfolder. + + +___ + +
+ + +
+Fix: Hardcoded main site and wrongly copied workfile #5733 + +Fixing these two issues: +- Hardcoded main site -> Replaced by `anatomy.fill_root`. +- Workfiles can sometimes be copied while they shouldn't. + + +___ + +
+ + +
+Bugfix: ServerDeleteOperation asset -> folder conversion typo #5735 + +Fix ServerDeleteOperation asset -> folder conversion typo + + +___ + +
+ + +
+Nuke: loaders are filtering correctly #5739 + +Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to. + + +___ + +
+ + +
+Nuke: failing multiple thumbnails integration #5741 + +This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails` + + +___ + +
+ + +
+AYON Settings: Fix global overrides #5745 + +The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides. + + +___ + +
+ + +
+Chore: AYON query functions arguments #5752 + +Fixed how `archived` argument is handled in get subsets/assets function. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id #5668 + +Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__` + + +___ + +
+ + +
+Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost #5701 + +Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Maya reduce get project settings calls #5669 + +Re-use system settings / project settings where we can instead of requerying. + + +___ + +
+ + +
+Extended error message when getting subset name #5649 + +Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message: + + +___ + +
+ + +
+Tests: Remove checks for env var #5696 + +Env var will be filled in `env_var` fixture, here it is too early to check + + +___ + +
+ + + + ## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1) diff --git a/openpype/version.py b/openpype/version.py index 1a316df989..b0a79162b2 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2-nightly.4" +__version__ = "3.17.2" diff --git a/pyproject.toml b/pyproject.toml index 2460185bdd..ad93b70c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.1" # OpenPype +version = "3.17.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From f5150665bd76b0ca27118b90cd1ce136cd899f8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Oct 2023 12:48:17 +0000 Subject: [PATCH 291/327] 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 f74904f79d..25f36ebc9a 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.17.2 - 3.17.2-nightly.4 - 3.17.2-nightly.3 - 3.17.2-nightly.2 @@ -134,7 +135,6 @@ body: - 3.14.11-nightly.2 - 3.14.11-nightly.1 - 3.14.10 - - 3.14.10-nightly.9 validations: required: true - type: dropdown From b92bc4b20236d14e9a1ebaf6ed8250820b190319 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 12 Oct 2023 14:18:11 +0100 Subject: [PATCH 292/327] Update tests/integration/hosts/maya/input/startup/userSetup.py Co-authored-by: Roy Nieterau --- tests/integration/hosts/maya/input/startup/userSetup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/hosts/maya/input/startup/userSetup.py b/tests/integration/hosts/maya/input/startup/userSetup.py index bb73ec7ee0..eb6e2411b5 100644 --- a/tests/integration/hosts/maya/input/startup/userSetup.py +++ b/tests/integration/hosts/maya/input/startup/userSetup.py @@ -8,13 +8,13 @@ import pyblish.util def setup_pyblish_logging(): log = logging.getLogger("pyblish") - hnd = logging.StreamHandler(sys.stdout) - fmt = logging.Formatter( + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter( "pyblish (%(levelname)s) (line: %(lineno)d) %(name)s:" "\n%(message)s" ) - hnd.setFormatter(fmt) - log.addHandler(hnd) + handler.setFormatter(formatter) + log.addHandler(handler) def _run_publish_test_deferred(): From 61f381cb5cee14f9f2c85b6db04c9144f9818ac5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 15:20:51 +0200 Subject: [PATCH 293/327] resolve: make sure of file existence --- openpype/hosts/resolve/api/lib.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 4066dd34fd..37410c9727 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -196,7 +196,7 @@ def create_media_pool_item( Create media pool item. Args: - fpath (str): absolute path to a file + files (list[str]): list of absolute paths to files root (resolve.Folder)[optional]: root folder / bin object Returns: @@ -206,8 +206,13 @@ def create_media_pool_item( media_pool = get_current_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() + # make sure files list is not empty and first available file exists + filepath = next((f for f in files if os.path.isfile(f)), None) + if not filepath: + raise FileNotFoundError("No file found in input files list") + # try to search in bin if the clip does not exist - existing_mpi = get_media_pool_item(files[0], root_bin) + existing_mpi = get_media_pool_item(filepath, root_bin) if existing_mpi: return existing_mpi From f03be42e9d62882a501303abfbee37b83463c946 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 15:30:39 +0200 Subject: [PATCH 294/327] resolve: improving key calling from version data --- openpype/hosts/resolve/api/plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index a0dba6fd05..8381f81acb 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -386,12 +386,13 @@ class ClipLoader: """Load clip into timeline Arguments: - files (list): list of files to load into timeline + files (list[str]): list of files to load into timeline """ # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - handle_start = self.data["versionData"].get("handleStart", 0) - handle_end = self.data["versionData"].get("handleEnd", 0) + + handle_start = self.data["versionData"].get("handleStart") or 0 + handle_end = self.data["versionData"].get("handleEnd") or 0 media_pool_item = lib.create_media_pool_item( files, From dfbc11bca505fbc06de8868453e28ded2f2b6072 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 16:41:20 +0200 Subject: [PATCH 295/327] wrong action name in exception --- openpype/hosts/nuke/plugins/publish/validate_asset_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 3a5678d61d..09cb5102a5 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -24,7 +24,7 @@ class SelectInvalidNodesAction(pyblish.api.Action): def process(self, context, plugin): if not hasattr(plugin, "select"): - raise RuntimeError("Plug-in does not have repair method.") + raise RuntimeError("Plug-in does not have select method.") # Get the failed instances self.log.debug("Finding failed plug-ins..") From 0953dc65cc915bd78cd1b37ba4305ce4fc705aa8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 17:16:35 +0200 Subject: [PATCH 296/327] utilization of already created action in nuke api openpype.hosts.nuke.api.action.SelectInvalidAction --- openpype/hosts/nuke/api/__init__.py | 6 ++- openpype/hosts/nuke/api/actions.py | 38 +++++++++---------- .../plugins/publish/validate_asset_context.py | 38 ++----------------- 3 files changed, 26 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1af5ff365d..a01f5bda0a 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -50,6 +50,8 @@ from .utils import ( get_colorspace_list ) +from .actions import SelectInvalidAction + __all__ = ( "file_extensions", "has_unsaved_changes", @@ -92,5 +94,7 @@ __all__ = ( "create_write_node", "colorspace_exists_on_node", - "get_colorspace_list" + "get_colorspace_list", + + "SelectInvalidAction", ) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index c955a85acc..ca3c8393ed 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -20,33 +20,31 @@ class SelectInvalidAction(pyblish.api.Action): def process(self, context, plugin): - try: - import nuke - except ImportError: - raise ImportError("Current host is not Nuke") - - errored_instances = get_errored_instances_from_context(context, - plugin=plugin) + # Get the errored instances for the plug-in + errored_instances = get_errored_instances_from_context( + context, plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") - invalid = list() + invalid_nodes = set() for instance in errored_instances: - invalid_nodes = plugin.get_invalid(instance) + invalid = plugin.get_invalid(instance) - if invalid_nodes: - if isinstance(invalid_nodes, (list, tuple)): - invalid.append(invalid_nodes[0]) - else: - self.log.warning("Plug-in returned to be invalid, " - "but has no selectable nodes.") + if not invalid: + continue - # Ensure unique (process each node only once) - invalid = list(set(invalid)) + select_node = instance.data.get("transientData", {}).get("node") + if not select_node: + raise RuntimeError( + "No transientData['node'] found on instance: {}".format( + instance) + ) - if invalid: - self.log.info("Selecting invalid nodes: {}".format(invalid)) + invalid_nodes.add(select_node) + + if invalid_nodes: + self.log.info("Selecting invalid nodes: {}".format(invalid_nodes)) reset_selection() - select_nodes(invalid) + select_nodes(list(invalid_nodes)) else: self.log.info("No invalid nodes found.") diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 09cb5102a5..aa96846799 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -4,34 +4,13 @@ from __future__ import absolute_import import pyblish.api -import openpype.hosts.nuke.api.lib as nlib - from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishXmlValidationError, - OptionalPyblishPluginMixin, - get_errored_instances_from_context + OptionalPyblishPluginMixin ) - - -class SelectInvalidNodesAction(pyblish.api.Action): - """Select invalid nodes.""" - - label = "Select Failed Node" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - if not hasattr(plugin, "select"): - raise RuntimeError("Plug-in does not have select method.") - - # Get the failed instances - self.log.debug("Finding failed plug-ins..") - failed_instance = get_errored_instances_from_context(context, plugin) - if failed_instance: - self.log.debug("Attempting selection ...") - plugin.select(failed_instance.pop()) +from openpype.hosts.nuke.api import SelectInvalidAction class ValidateCorrectAssetContext( @@ -51,7 +30,7 @@ class ValidateCorrectAssetContext( hosts = ["nuke"] actions = [ RepairAction, - SelectInvalidNodesAction, + SelectInvalidAction ] optional = True @@ -133,14 +112,3 @@ class ValidateCorrectAssetContext( created_instance[_key] = instance.context.data[_key] create_context.save_changes() - - @classmethod - def select(cls, instance): - """Select invalid node """ - invalid = cls.get_invalid(instance) - if not invalid: - return - - select_node = instance.data["transientData"]["node"] - nlib.reset_selection() - select_node["selected"].setValue(True) From 4c90065f43930c35e1ad37712b0ec74d49a0b4d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 17:22:51 +0200 Subject: [PATCH 297/327] apply setting fix so it works in deprecated and new configuration --- .../nuke/plugins/publish/validate_asset_context.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index aa96846799..384cfab7b2 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -39,13 +39,14 @@ class ValidateCorrectAssetContext( """Apply deprecated settings from project settings. """ nuke_publish = project_settings["nuke"]["publish"] - if "ValidateCorrectAssetName" not in nuke_publish: - return + if "ValidateCorrectAssetName" in nuke_publish: + settings = nuke_publish["ValidateCorrectAssetName"] + else: + settings = nuke_publish["ValidateCorrectAssetContext"] - deprecated_setting = nuke_publish["ValidateCorrectAssetName"] - cls.enabled = deprecated_setting["enabled"] - cls.optional = deprecated_setting["optional"] - cls.active = deprecated_setting["active"] + cls.enabled = settings["enabled"] + cls.optional = settings["optional"] + cls.active = settings["active"] def process(self, instance): if not self.is_active(instance.data): From ecf144993fb48c0e6d9d8e7c8c0512f805abafe6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 17:33:58 +0200 Subject: [PATCH 298/327] optimisation of validator and xml message --- .../publish/help/validate_asset_context.xml | 26 ++++++++++++----- .../plugins/publish/validate_asset_context.py | 29 +++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml index 85efef799a..6d3a9724db 100644 --- a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml +++ b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml @@ -3,19 +3,29 @@ Shot/Asset name -## Invalid node context keys and values +## Publishing to a different asset context -Following Node with name: \`{node_name}\` +There are publish instances present which are publishing into a different asset than your current context. -Context keys and values: \`{correct_values}\` +Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task. -Wrong keys and values: \`{wrong_values}\`. +If that's the case you can disable the validation on the instance to ignore it. -### How to repair? +Following Node with name is wrong: \`{node_name}\` -1. Either use Repair or Select button. -2. If you chose Select then rename asset knob to correct name. -3. Hit Reload button on the publisher. +### Correct context keys and values: + +\`{correct_values}\` + +### Wrong keys and values: + +\`{wrong_values}\`. + + +## How to repair? + +1. Use \"Repair\" button. +2. Hit Reload button on the publisher. diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index 384cfab7b2..ab62daeaeb 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -52,7 +52,7 @@ class ValidateCorrectAssetContext( if not self.is_active(instance.data): return - invalid_keys = self.get_invalid(instance, compute=True) + invalid_keys = self.get_invalid(instance) if not invalid_keys: return @@ -81,27 +81,24 @@ class ValidateCorrectAssetContext( ) @classmethod - def get_invalid(cls, instance, compute=False): + def get_invalid(cls, instance): """Get invalid keys from instance data and context data.""" - invalid = instance.data.get("invalid_keys", []) - if compute: - testing_keys = ["asset", "task"] - for _key in testing_keys: - if _key not in instance.data: - invalid.append(_key) - continue - if instance.data[_key] != instance.context.data[_key]: - invalid.append(_key) + invalid_keys = [] + testing_keys = ["asset", "task"] + for _key in testing_keys: + if _key not in instance.data: + invalid_keys.append(_key) + continue + if instance.data[_key] != instance.context.data[_key]: + invalid_keys.append(_key) - instance.data["invalid_keys"] = invalid - - return invalid + return invalid_keys @classmethod def repair(cls, instance): """Repair instance data with context data.""" - invalid = cls.get_invalid(instance) + invalid_keys = cls.get_invalid(instance) create_context = instance.context.data["create_context"] @@ -109,7 +106,7 @@ class ValidateCorrectAssetContext( created_instance = create_context.get_instance_by_id( instance_id ) - for _key in invalid: + for _key in invalid_keys: created_instance[_key] = instance.context.data[_key] create_context.save_changes() From 9c53837c334acefb792972e3b4a28b414707583d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Oct 2023 10:40:41 +0200 Subject: [PATCH 299/327] AYON: Tools enhancements (#5753) * moved universal 'TreeView' to utils * use 'TreeView' in folders widget * propagate 'set_deselectable' in 'FoldersWidget' * propagate more public functionality of 'FoldersWidget' * fix 'set_name_filer' typo * rename 'get_current_project_name' to 'get_selected_project_name' * added signals to task and project widgets * implemented more helper methods in hierarchy model * added more information to 'FolderItem' * add empty line after docstring * fix expected selection of folders in loader * keep only 'double_clicked' signal * pass full mouse event to signal --- .../tools/ayon_launcher/ui/hierarchy_page.py | 2 +- .../tools/ayon_loader/ui/folders_widget.py | 19 +- .../tools/ayon_loader/ui/products_widget.py | 2 +- openpype/tools/ayon_loader/ui/window.py | 8 +- openpype/tools/ayon_utils/models/hierarchy.py | 107 ++++++++++- .../ayon_utils/widgets/folders_widget.py | 172 +++++++++++++++--- .../ayon_utils/widgets/projects_widget.py | 8 +- .../tools/ayon_utils/widgets/tasks_widget.py | 5 + .../widgets/files_widget_published.py | 10 +- .../widgets/files_widget_workarea.py | 10 +- .../ayon_workfiles/widgets/folders_widget.py | 2 +- .../tools/ayon_workfiles/widgets/utils.py | 66 ------- .../tools/ayon_workfiles/widgets/window.py | 2 +- openpype/tools/utils/__init__.py | 6 +- openpype/tools/utils/views.py | 62 +++++++ 15 files changed, 350 insertions(+), 131 deletions(-) diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py index 8c546b38ac..d56d43fdec 100644 --- a/openpype/tools/ayon_launcher/ui/hierarchy_page.py +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -103,4 +103,4 @@ class HierarchyPage(QtWidgets.QWidget): self._controller.refresh() def _on_filter_text_changed(self, text): - self._folders_widget.set_name_filer(text) + self._folders_widget.set_name_filter(text) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index b911458546..53351f76d9 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -11,14 +11,14 @@ from openpype.tools.ayon_utils.widgets import ( FoldersModel, FOLDERS_MODEL_SENDER_NAME, ) -from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE +from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE if qtpy.API == "pyside": from PySide.QtGui import QStyleOptionViewItemV4 elif qtpy.API == "pyqt4": from PyQt4.QtGui import QStyleOptionViewItemV4 -UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 +UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50 class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): @@ -257,13 +257,11 @@ class LoaderFoldersWidget(QtWidgets.QWidget): Args: controller (AbstractWorkfilesFrontend): The control object. parent (QtWidgets.QWidget): The parent widget. - handle_expected_selection (bool): If True, the widget will handle - the expected selection. Defaults to False. """ refreshed = QtCore.Signal() - def __init__(self, controller, parent, handle_expected_selection=False): + def __init__(self, controller, parent): super(LoaderFoldersWidget, self).__init__(parent) folders_view = DeselectableTreeView(self) @@ -313,10 +311,9 @@ class LoaderFoldersWidget(QtWidgets.QWidget): self._folders_proxy_model = folders_proxy_model self._folders_label_delegate = folders_label_delegate - self._handle_expected_selection = handle_expected_selection self._expected_selection = None - def set_name_filer(self, name): + def set_name_filter(self, name): """Set filter of folder name. Args: @@ -365,7 +362,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget): selection_model = self._folders_view.selectionModel() item_ids = [] for index in selection_model.selectedIndexes(): - item_id = index.data(ITEM_ID_ROLE) + item_id = index.data(FOLDER_ID_ROLE) if item_id is not None: item_ids.append(item_id) return item_ids @@ -379,9 +376,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget): self._update_expected_selection(event.data) def _update_expected_selection(self, expected_data=None): - if not self._handle_expected_selection: - return - if expected_data is None: expected_data = self._controller.get_expected_selection_data() @@ -395,9 +389,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget): self._set_expected_selection() def _set_expected_selection(self): - if not self._handle_expected_selection: - return - folder_id = self._expected_selection selected_ids = self._get_selected_item_ids() self._expected_selection = None diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index cfc18431a6..2d4959dc19 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -183,7 +183,7 @@ class ProductsWidget(QtWidgets.QWidget): not controller.is_loaded_products_supported() ) - def set_name_filer(self, name): + def set_name_filter(self, name): """Set filter of product name. Args: diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py index ca17e4b9fd..a6d40d52e7 100644 --- a/openpype/tools/ayon_loader/ui/window.py +++ b/openpype/tools/ayon_loader/ui/window.py @@ -382,7 +382,7 @@ class LoaderWindow(QtWidgets.QWidget): self._controller.reset() def _show_group_dialog(self): - project_name = self._projects_combobox.get_current_project_name() + project_name = self._projects_combobox.get_selected_project_name() if not project_name: return @@ -397,7 +397,7 @@ class LoaderWindow(QtWidgets.QWidget): self._group_dialog.show() def _on_folder_filter_change(self, text): - self._folders_widget.set_name_filer(text) + self._folders_widget.set_name_filter(text) def _on_product_group_change(self): self._products_widget.set_enable_grouping( @@ -405,7 +405,7 @@ class LoaderWindow(QtWidgets.QWidget): ) def _on_product_filter_change(self, text): - self._products_widget.set_name_filer(text) + self._products_widget.set_name_filter(text) def _on_product_type_filter_change(self): self._products_widget.set_product_type_filter( @@ -419,7 +419,7 @@ class LoaderWindow(QtWidgets.QWidget): def _on_products_selection_change(self): items = self._products_widget.get_selected_version_info() self._info_widget.set_selected_version_info( - self._projects_combobox.get_current_project_name(), + self._projects_combobox.get_selected_project_name(), items ) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py index 6c30d22f3a..fc6b8e1eb7 100644 --- a/openpype/tools/ayon_utils/models/hierarchy.py +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -29,16 +29,21 @@ class FolderItem: parent_id (Union[str, None]): Parent folder id. If 'None' then project is parent. name (str): Name of folder. + path (str): Folder path. + folder_type (str): Type of folder. label (Union[str, None]): Folder label. icon (Union[dict[str, Any], None]): Icon definition. """ def __init__( - self, entity_id, parent_id, name, label, icon + self, entity_id, parent_id, name, path, folder_type, label, icon ): self.entity_id = entity_id self.parent_id = parent_id self.name = name + self.path = path + self.folder_type = folder_type + self.label = label or name if not icon: icon = { "type": "awesome-font", @@ -46,7 +51,6 @@ class FolderItem: "color": get_default_entity_icon_color() } self.icon = icon - self.label = label or name def to_data(self): """Converts folder item to data. @@ -59,6 +63,8 @@ class FolderItem: "entity_id": self.entity_id, "parent_id": self.parent_id, "name": self.name, + "path": self.path, + "folder_type": self.folder_type, "label": self.label, "icon": self.icon, } @@ -90,8 +96,7 @@ class TaskItem: name (str): Name of task. task_type (str): Type of task. parent_id (str): Parent folder id. - icon_name (str): Name of icon from font awesome. - icon_color (str): Hex color string that will be used for icon. + icon (Union[dict[str, Any], None]): Icon definitions. """ def __init__( @@ -183,12 +188,31 @@ def _get_task_items_from_tasks(tasks): def _get_folder_item_from_hierarchy_item(item): + name = item["name"] + path_parts = list(item["parents"]) + path_parts.append(name) + return FolderItem( item["id"], item["parentId"], - item["name"], + name, + "/".join(path_parts), + item["folderType"], item["label"], - None + None, + ) + + +def _get_folder_item_from_entity(entity): + name = entity["name"] + return FolderItem( + entity["id"], + entity["parentId"], + name, + entity["path"], + entity["folderType"], + entity["label"] or name, + None, ) @@ -223,13 +247,84 @@ class HierarchyModel(object): self._tasks_by_id.reset() def refresh_project(self, project_name): + """Force to refresh folder items for a project. + + Args: + project_name (str): Name of project to refresh. + """ + self._refresh_folders_cache(project_name) def get_folder_items(self, project_name, sender): + """Get folder items by project name. + + The folders are cached per project name. If the cache is not valid + then the folders are queried from server. + + Args: + project_name (str): Name of project where to look for folders. + sender (Union[str, None]): Who requested the folder ids. + + Returns: + dict[str, FolderItem]: Folder items by id. + """ + if not self._folders_items[project_name].is_valid: self._refresh_folders_cache(project_name, sender) return self._folders_items[project_name].get_data() + def get_folder_items_by_id(self, project_name, folder_ids): + """Get folder items by ids. + + This function will query folders if they are not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[FolderItem, None]]: Folder items by id. + """ + + folder_ids = set(folder_ids) + if self._folders_items[project_name].is_valid: + cache_data = self._folders_items[project_name].get_data() + return { + folder_id: cache_data.get(folder_id) + for folder_id in folder_ids + } + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "name", "label", "parentId", "path", "folderType"] + ) + # Make sure all folder ids are in output + output = {folder_id: None for folder_id in folder_ids} + output.update({ + folder["id"]: _get_folder_item_from_entity(folder) + for folder in folders + }) + return output + + def get_folder_item(self, project_name, folder_id): + """Get folder items by id. + + This function will query folder if they is not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_id (str): Folder id. + + Returns: + Union[FolderItem, None]: Folder item. + """ + items = self.get_folder_items_by_id( + project_name, [folder_id] + ) + return items.get(folder_id) + def get_task_items(self, project_name, folder_id, sender): if not project_name or not folder_id: return [] diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index b57ffb126a..322553c51c 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -4,14 +4,16 @@ from qtpy import QtWidgets, QtGui, QtCore from openpype.tools.utils import ( RecursiveSortFilterProxyModel, - DeselectableTreeView, + TreeView, ) from .utils import RefreshThread, get_qt_icon FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" -ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 -ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 1 +FOLDER_NAME_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 class FoldersModel(QtGui.QStandardItemModel): @@ -84,6 +86,15 @@ class FoldersModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() return self.indexFromItem(item) + def get_project_name(self): + """Project name which model currently use. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._last_project_name + def set_project_name(self, project_name): """Refresh folders items. @@ -151,12 +162,13 @@ class FoldersModel(QtGui.QStandardItemModel): """ icon = get_qt_icon(folder_item.icon) - item.setData(folder_item.entity_id, ITEM_ID_ROLE) - item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.entity_id, FOLDER_ID_ROLE) + item.setData(folder_item.name, FOLDER_NAME_ROLE) + item.setData(folder_item.path, FOLDER_PATH_ROLE) + item.setData(folder_item.folder_type, FOLDER_TYPE_ROLE) item.setData(folder_item.label, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) - def _fill_items(self, folder_items_by_id): if not folder_items_by_id: if folder_items_by_id is not None: @@ -193,7 +205,7 @@ class FoldersModel(QtGui.QStandardItemModel): folder_ids_to_add = set(folder_items) for row_idx in reversed(range(parent_item.rowCount())): child_item = parent_item.child(row_idx) - child_id = child_item.data(ITEM_ID_ROLE) + child_id = child_item.data(FOLDER_ID_ROLE) if child_id in ids_to_remove: removed_items.append(parent_item.takeRow(row_idx)) else: @@ -259,10 +271,14 @@ class FoldersWidget(QtWidgets.QWidget): the expected selection. Defaults to False. """ + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + selection_changed = QtCore.Signal() + refreshed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(FoldersWidget, self).__init__(parent) - folders_view = DeselectableTreeView(self) + folders_view = TreeView(self) folders_view.setHeaderHidden(True) folders_model = FoldersModel(controller) @@ -295,7 +311,7 @@ class FoldersWidget(QtWidgets.QWidget): selection_model = folders_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - + folders_view.double_clicked.connect(self.double_clicked) folders_model.refreshed.connect(self._on_model_refresh) self._controller = controller @@ -306,7 +322,27 @@ class FoldersWidget(QtWidgets.QWidget): self._handle_expected_selection = handle_expected_selection self._expected_selection = None - def set_name_filer(self, name): + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + + return self._folders_model.is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._folders_model.has_content + + def set_name_filter(self, name): """Set filter of folder name. Args: @@ -323,16 +359,108 @@ class FoldersWidget(QtWidgets.QWidget): self._folders_model.refresh() + def get_project_name(self): + """Project name in which folders widget currently is. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._folders_model.get_project_name() + + def set_project_name(self, project_name): + """Set project name. + + Do not use this method when controller is handling selection of + project using 'selection.project.changed' event. + + Args: + project_name (str): Project name. + """ + + self._folders_model.set_project_name(project_name) + + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Folder id which is selected. + """ + + return self._get_selected_item_id() + + def get_selected_folder_label(self): + """Selected folder label. + + Returns: + Union[str, None]: Selected folder label. + """ + + item_id = self._get_selected_item_id() + return self.get_folder_label(item_id) + + def get_folder_label(self, folder_id): + """Folder label for a given folder id. + + Returns: + Union[str, None]: Folder label. + """ + + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + return index.data(QtCore.Qt.DisplayRole) + return None + + def set_selected_folder(self, folder_id): + """Change selection. + + Args: + folder_id (Union[str, None]): Folder id or None to deselect. + """ + + if folder_id is None: + self._folders_view.clearSelection() + return True + + if folder_id == self._get_selected_item_id(): + return True + index = self._folders_model.get_index_by_id(folder_id) + if not index.isValid(): + return False + + proxy_index = self._folders_proxy_model.mapFromSource(index) + if not proxy_index.isValid(): + return False + + selection_model = self._folders_view.selectionModel() + selection_model.setCurrentIndex( + proxy_index, QtCore.QItemSelectionModel.SelectCurrent + ) + return True + + def set_deselectable(self, enabled): + """Set deselectable mode. + + Items in view can be deselected. + + Args: + enabled (bool): Enable deselectable mode. + """ + + self._folders_view.set_deselectable(enabled) + + def _get_selected_index(self): + return self._folders_model.get_index_by_id( + self.get_selected_folder_id() + ) + def _on_project_selection_change(self, event): project_name = event["project_name"] - self._set_project_name(project_name) - - def _set_project_name(self, project_name): - self._folders_model.set_project_name(project_name) + self.set_project_name(project_name) def _on_folders_refresh_finished(self, event): if event["sender"] != FOLDERS_MODEL_SENDER_NAME: - self._set_project_name(event["project_name"]) + self.set_project_name(event["project_name"]) def _on_controller_refresh(self): self._update_expected_selection() @@ -341,11 +469,12 @@ class FoldersWidget(QtWidgets.QWidget): if self._expected_selection: self._set_expected_selection() self._folders_proxy_model.sort(0) + self.refreshed.emit() def _get_selected_item_id(self): selection_model = self._folders_view.selectionModel() for index in selection_model.selectedIndexes(): - item_id = index.data(ITEM_ID_ROLE) + item_id = index.data(FOLDER_ID_ROLE) if item_id is not None: return item_id return None @@ -353,6 +482,7 @@ class FoldersWidget(QtWidgets.QWidget): def _on_selection_change(self): item_id = self._get_selected_item_id() self._controller.set_selected_folder(item_id) + self.selection_changed.emit() # Expected selection handling def _on_expected_selection_change(self, event): @@ -380,12 +510,6 @@ class FoldersWidget(QtWidgets.QWidget): folder_id = self._expected_selection self._expected_selection = None - if ( - folder_id is not None - and folder_id != self._get_selected_item_id() - ): - index = self._folders_model.get_index_by_id(folder_id) - if index.isValid(): - proxy_index = self._folders_proxy_model.mapFromSource(index) - self._folders_view.setCurrentIndex(proxy_index) + if folder_id is not None: + self.set_selected_folder(folder_id) self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 11bb5de51b..be18cfe3ed 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -395,6 +395,7 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() def __init__(self, controller, parent, handle_expected_selection=False): super(ProjectsCombobox, self).__init__(parent) @@ -482,7 +483,7 @@ class ProjectsCombobox(QtWidgets.QWidget): self._listen_selection_change = listen - def get_current_project_name(self): + def get_selected_project_name(self): """Name of selected project. Returns: @@ -502,7 +503,7 @@ class ProjectsCombobox(QtWidgets.QWidget): if not self._select_item_visible: return if "project_name" not in kwargs: - project_name = self.get_current_project_name() + project_name = self.get_selected_project_name() else: project_name = kwargs.get("project_name") @@ -536,6 +537,7 @@ class ProjectsCombobox(QtWidgets.QWidget): idx, PROJECT_NAME_ROLE) self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) + self.selection_changed.emit() def _on_model_refresh(self): self._projects_proxy_model.sort(0) @@ -561,7 +563,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return project_name = self._expected_selection if project_name is not None: - if project_name != self.get_current_project_name(): + if project_name != self.get_selected_project_name(): self.set_selection(project_name) else: # Fake project change diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index da745bd810..d01b3a7917 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -296,6 +296,9 @@ class TasksWidget(QtWidgets.QWidget): handle_expected_selection (Optional[bool]): Handle expected selection. """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + def __init__(self, controller, parent, handle_expected_selection=False): super(TasksWidget, self).__init__(parent) @@ -380,6 +383,7 @@ class TasksWidget(QtWidgets.QWidget): if not self._set_expected_selection(): self._on_selection_change() self._tasks_proxy_model.sort(0) + self.refreshed.emit() def _get_selected_item_ids(self): selection_model = self._tasks_view.selectionModel() @@ -400,6 +404,7 @@ class TasksWidget(QtWidgets.QWidget): parent_id, task_id, task_name = self._get_selected_item_ids() self._controller.set_selected_task(task_id, task_name) + self.selection_changed.emit() # Expected selection handling def _on_expected_selection_change(self, event): diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py index bc59447777..576cf18d73 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -5,9 +5,10 @@ from openpype.style import ( get_default_entity_icon_color, get_disabled_entity_icon_color, ) +from openpype.tools.utils import TreeView from openpype.tools.utils.delegates import PrettyTimeDelegate -from .utils import TreeView, BaseOverlayFrame +from .utils import BaseOverlayFrame REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 @@ -306,7 +307,7 @@ class PublishedFilesWidget(QtWidgets.QWidget): selection_model = view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - view.double_clicked_left.connect(self._on_left_double_click) + view.double_clicked.connect(self._on_mouse_double_click) controller.register_event_callback( "expected_selection_changed", @@ -350,8 +351,9 @@ class PublishedFilesWidget(QtWidgets.QWidget): repre_id = self.get_selected_repre_id() self._controller.set_selected_representation_id(repre_id) - def _on_left_double_click(self): - self.save_as_requested.emit() + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.save_as_requested.emit() def _on_expected_selection_change(self, event): if ( diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py index e8ccd094d1..e59b319459 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -5,10 +5,9 @@ from openpype.style import ( get_default_entity_icon_color, get_disabled_entity_icon_color, ) +from openpype.tools.utils import TreeView from openpype.tools.utils.delegates import PrettyTimeDelegate -from .utils import TreeView - FILENAME_ROLE = QtCore.Qt.UserRole + 1 FILEPATH_ROLE = QtCore.Qt.UserRole + 2 DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 @@ -271,7 +270,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): selection_model = view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) - view.double_clicked_left.connect(self._on_left_double_click) + view.double_clicked.connect(self._on_mouse_double_click) view.customContextMenuRequested.connect(self._on_context_menu) controller.register_event_callback( @@ -333,8 +332,9 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): filepath = self.get_selected_path() self._controller.set_selected_workfile_path(filepath) - def _on_left_double_click(self): - self.open_current_requested.emit() + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.save_as_requested.emit() def _on_context_menu(self, point): index = self._view.indexAt(point) diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py index b35845f4b6..b04f8e4098 100644 --- a/openpype/tools/ayon_workfiles/widgets/folders_widget.py +++ b/openpype/tools/ayon_workfiles/widgets/folders_widget.py @@ -264,7 +264,7 @@ class FoldersWidget(QtWidgets.QWidget): self._expected_selection = None - def set_name_filer(self, name): + def set_name_filter(self, name): self._folders_proxy_model.setFilterFixedString(name) def _clear(self): diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py index 6a61239f8d..9171638546 100644 --- a/openpype/tools/ayon_workfiles/widgets/utils.py +++ b/openpype/tools/ayon_workfiles/widgets/utils.py @@ -1,70 +1,4 @@ from qtpy import QtWidgets, QtCore -from openpype.tools.flickcharm import FlickCharm - - -class TreeView(QtWidgets.QTreeView): - """Ultimate TreeView with flick charm and double click signals. - - Tree view have deselectable mode, which allows to deselect items by - clicking on item area without any items. - - Todos: - Add to tools utils. - """ - - double_clicked_left = QtCore.Signal() - double_clicked_right = QtCore.Signal() - - def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) - self._deselectable = False - - self._flick_charm_activated = False - self._flick_charm = FlickCharm(parent=self) - self._before_flick_scroll_mode = None - - def is_deselectable(self): - return self._deselectable - - def set_deselectable(self, deselectable): - self._deselectable = deselectable - - deselectable = property(is_deselectable, set_deselectable) - - def mousePressEvent(self, event): - if self._deselectable: - index = self.indexAt(event.pos()) - if not index.isValid(): - # clear the selection - self.clearSelection() - # clear the current index - self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) - - def mouseDoubleClickEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - self.double_clicked_left.emit() - - elif event.button() == QtCore.Qt.RightButton: - self.double_clicked_right.emit() - - return super(TreeView, self).mouseDoubleClickEvent(event) - - def activate_flick_charm(self): - if self._flick_charm_activated: - return - self._flick_charm_activated = True - self._before_flick_scroll_mode = self.verticalScrollMode() - self._flick_charm.activateOn(self) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - - def deactivate_flick_charm(self): - if not self._flick_charm_activated: - return - self._flick_charm_activated = False - self._flick_charm.deactivateFrom(self) - if self._before_flick_scroll_mode is not None: - self.setVerticalScrollMode(self._before_flick_scroll_mode) class BaseOverlayFrame(QtWidgets.QFrame): diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py index ef352c8b18..6218d2dd06 100644 --- a/openpype/tools/ayon_workfiles/widgets/window.py +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -338,7 +338,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._side_panel.set_published_mode(published_mode) def _on_folder_filter_change(self, text): - self._folder_widget.set_name_filer(text) + self._folder_widget.set_name_filter(text) def _on_go_to_current_clicked(self): self._controller.go_to_current_context() diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index ed41d93f0d..50d50f467a 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -20,7 +20,10 @@ from .widgets import ( RefreshButton, GoToCurrentButton, ) -from .views import DeselectableTreeView +from .views import ( + DeselectableTreeView, + TreeView, +) from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -71,6 +74,7 @@ __all__ = ( "GoToCurrentButton", "DeselectableTreeView", + "TreeView", "ErrorMessageBox", diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py index 01919d6745..596a47ede9 100644 --- a/openpype/tools/utils/views.py +++ b/openpype/tools/utils/views.py @@ -1,4 +1,6 @@ from openpype.resources import get_image_path +from openpype.tools.flickcharm import FlickCharm + from qtpy import QtWidgets, QtCore, QtGui, QtSvg @@ -57,3 +59,63 @@ class TreeViewSpinner(QtWidgets.QTreeView): self.paint_empty(event) else: super(TreeViewSpinner, self).paintEvent(event) + + +class TreeView(QtWidgets.QTreeView): + """Ultimate TreeView with flick charm and double click signals. + + Tree view have deselectable mode, which allows to deselect items by + clicking on item area without any items. + + Todos: + Add refresh animation. + """ + + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super(TreeView, self).__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super(TreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super(TreeView, self).mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) From 8848d846975cef0013c1df18473d96da330c4418 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Oct 2023 10:53:27 +0200 Subject: [PATCH 300/327] removed 'update_hierarchy' (#5756) --- openpype/hosts/blender/api/pipeline.py | 30 -------------------------- 1 file changed, 30 deletions(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 29339a512c..84af0904f0 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -460,36 +460,6 @@ def ls() -> Iterator: yield parse_container(container) -def update_hierarchy(containers): - """Hierarchical container support - - This is the function to support Scene Inventory to draw hierarchical - view for containers. - - We need both parent and children to visualize the graph. - - """ - - all_containers = set(ls()) # lookup set - - for container in containers: - # Find parent - # FIXME (jasperge): re-evaluate this. How would it be possible - # to 'nest' assets? Collections can have several parents, for - # now assume it has only 1 parent - parent = [ - coll for coll in bpy.data.collections if container in coll.children - ] - for node in parent: - if node in all_containers: - container["parent"] = node - break - - log.debug("Container: %s", container) - - yield container - - def publish(): """Shorthand to publish from within host.""" From f17ab23477fa1f48e905c7be62b5982a66bcd8f9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 Oct 2023 11:22:17 +0200 Subject: [PATCH 301/327] removing debug logging --- .../nuke/plugins/publish/validate_write_nodes.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 2a925fbeff..9aae53e59d 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -82,12 +82,6 @@ class ValidateNukeWriteNode( correct_data = get_write_node_template_attr(write_group_node) check = [] - self.log.debug("__ write_node: {}".format( - write_node - )) - self.log.debug("__ correct_data: {}".format( - correct_data - )) # Collect key values of same type in a list. values_by_name = defaultdict(list) @@ -96,9 +90,6 @@ class ValidateNukeWriteNode( for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] - self.log.debug("__ knob_type: {}".format( - knob_type - )) if ( knob_type == "__legacy__" @@ -134,9 +125,6 @@ class ValidateNukeWriteNode( fixed_values.append(value) - self.log.debug("__ key: {} | values: {}".format( - key, fixed_values - )) if ( node_value not in fixed_values and key != "file" @@ -144,8 +132,6 @@ class ValidateNukeWriteNode( ): check.append([key, value, write_node[key].value()]) - self.log.info(check) - if check: self._make_error(check) From b993cea40b1261dd78121c2bf39700cedb02c942 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 17:36:57 +0800 Subject: [PATCH 302/327] rename validate max contents to validate container & add related families to check the container contents --- .../plugins/publish/validate_containers.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 openpype/hosts/max/plugins/publish/validate_containers.py diff --git a/openpype/hosts/max/plugins/publish/validate_containers.py b/openpype/hosts/max/plugins/publish/validate_containers.py new file mode 100644 index 0000000000..a5c0669a11 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_containers.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateContainers(pyblish.api.InstancePlugin): + """Validates Containers. + + Check if MaxScene containers includes any contents underneath. + """ + + order = pyblish.api.ValidatorOrder + families = ["camera", + "model", + "maxScene", + "review", + "pointcache", + "pointcloud", + "redshiftproxy"] + hosts = ["max"] + label = "Container Contents" + + def process(self, instance): + if not instance.data["members"]: + raise PublishValidationError("No content found in the container") From 59dc6d2813554b3c19f69632ae8ff206d87b0c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 13 Oct 2023 12:07:33 +0200 Subject: [PATCH 303/327] Update openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml Co-authored-by: Roy Nieterau --- .../hosts/nuke/plugins/publish/help/validate_asset_context.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml index 6d3a9724db..d9394ae510 100644 --- a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml +++ b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml @@ -11,7 +11,7 @@ Usually this is not what you want but there can be cases where you might want to If that's the case you can disable the validation on the instance to ignore it. -Following Node with name is wrong: \`{node_name}\` +The wrong node's name is: \`{node_name}\` ### Correct context keys and values: From 70d8c72c96fab9cb7d1196a02ae3b1354fe86a9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Oct 2023 13:38:55 +0200 Subject: [PATCH 304/327] Scene Inventory tool: Refactor Scene Inventory tool (for AYON) (#5758) * propagate 'set_deselectable' in 'FoldersWidget' * propagate more public functionality of 'FoldersWidget' * initial commit duplicated current sceneinventory to ayon_sceneinventory * implemented basic controller helper * use the controller in UI * minor modifications * initial changes of switch dialog * moved 'get_containers' to controller * refresh scene inventory after show * fix passed argument to InventoryModel * removed vertical expanding * tweaks of folder input * initial changes in switch dialog * fix selection of folder * use new scene inventory in host tools * fix the size policy of FoldersField * fix current context folder id * fix current folder change * renamed asset > folder and subset > product * removed duplicated methods after rebase * removed unused import * formatting fix * try to query only valid UUID * query all containers documents at once * validate representation ids in view too * use 'container' variable instead of 'item' --- .../tools/ayon_sceneinventory/__init__.py | 6 + openpype/tools/ayon_sceneinventory/control.py | 134 ++ openpype/tools/ayon_sceneinventory/model.py | 622 ++++++++ .../ayon_sceneinventory/models/__init__.py | 6 + .../ayon_sceneinventory/models/site_sync.py | 176 +++ .../switch_dialog/__init__.py | 6 + .../switch_dialog/dialog.py | 1333 +++++++++++++++++ .../switch_dialog/folders_input.py | 307 ++++ .../switch_dialog/widgets.py | 94 ++ openpype/tools/ayon_sceneinventory/view.py | 825 ++++++++++ openpype/tools/ayon_sceneinventory/window.py | 200 +++ openpype/tools/utils/delegates.py | 7 +- openpype/tools/utils/host_tools.py | 19 +- 13 files changed, 3728 insertions(+), 7 deletions(-) create mode 100644 openpype/tools/ayon_sceneinventory/__init__.py create mode 100644 openpype/tools/ayon_sceneinventory/control.py create mode 100644 openpype/tools/ayon_sceneinventory/model.py create mode 100644 openpype/tools/ayon_sceneinventory/models/__init__.py create mode 100644 openpype/tools/ayon_sceneinventory/models/site_sync.py create mode 100644 openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py create mode 100644 openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py create mode 100644 openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py create mode 100644 openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py create mode 100644 openpype/tools/ayon_sceneinventory/view.py create mode 100644 openpype/tools/ayon_sceneinventory/window.py diff --git a/openpype/tools/ayon_sceneinventory/__init__.py b/openpype/tools/ayon_sceneinventory/__init__.py new file mode 100644 index 0000000000..5412e2fea2 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/__init__.py @@ -0,0 +1,6 @@ +from .control import SceneInventoryController + + +__all__ = ( + "SceneInventoryController", +) diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py new file mode 100644 index 0000000000..e98b0e307b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/control.py @@ -0,0 +1,134 @@ +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.host import ILoadHost +from openpype.pipeline import ( + registered_host, + get_current_context, +) +from openpype.tools.ayon_utils.models import HierarchyModel + +from .models import SiteSyncModel + + +class SceneInventoryController: + """This is a temporary controller for AYON. + + Goal of this temporary controller is to provide a way to get current + context instead of using 'AvalonMongoDB' object (or 'legacy_io'). + + Also provides (hopefully) cleaner api for site sync. + """ + + def __init__(self, host=None): + if host is None: + host = registered_host() + self._host = host + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model = SiteSyncModel(self) + # Switch dialog requirements + self._hierarchy_model = HierarchyModel(self) + self._event_system = self._create_event_system() + + def emit_event(self, topic, data=None, source=None): + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model.reset() + self._hierarchy_model.reset() + + def get_current_context(self): + if self._current_context is None: + if hasattr(self._host, "get_current_context"): + self._current_context = self._host.get_current_context() + else: + self._current_context = get_current_context() + return self._current_context + + def get_current_project_name(self): + if self._current_project is None: + self._current_project = self.get_current_context()["project_name"] + return self._current_project + + def get_current_folder_id(self): + if self._current_folder_set: + return self._current_folder_id + + context = self.get_current_context() + project_name = context["project_name"] + folder_path = context.get("folder_path") + folder_name = context.get("asset_name") + folder_id = None + if folder_path: + folder = ayon_api.get_folder_by_path(project_name, folder_path) + if folder: + folder_id = folder["id"] + elif folder_name: + for folder in ayon_api.get_folders( + project_name, folder_names=[folder_name] + ): + folder_id = folder["id"] + break + + self._current_folder_id = folder_id + self._current_folder_set = True + return self._current_folder_id + + def get_containers(self): + host = self._host + if isinstance(host, ILoadHost): + return host.get_containers() + elif hasattr(host, "ls"): + return host.ls() + return [] + + # Site Sync methods + def is_sync_server_enabled(self): + return self._site_sync_model.is_sync_server_enabled() + + def get_sites_information(self): + return self._site_sync_model.get_sites_information() + + def get_site_provider_icons(self): + return self._site_sync_model.get_site_provider_icons() + + def get_representations_site_progress(self, representation_ids): + return self._site_sync_model.get_representations_site_progress( + representation_ids + ) + + def resync_representations(self, representation_ids, site_type): + return self._site_sync_model.resync_representations( + representation_ids, site_type + ) + + # Switch dialog methods + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_folder_label(self, folder_id): + if not folder_id: + return None + project_name = self.get_current_project_name() + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + if folder_item is None: + return None + return folder_item.label + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_sceneinventory/model.py b/openpype/tools/ayon_sceneinventory/model.py new file mode 100644 index 0000000000..16924b0a7e --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/model.py @@ -0,0 +1,622 @@ +import collections +import re +import logging +import uuid +import copy + +from collections import defaultdict + +from qtpy import QtCore, QtGui +import qtawesome + +from openpype.client import ( + get_assets, + get_subsets, + get_versions, + get_last_version_by_subset_id, + get_representations, +) +from openpype.pipeline import ( + get_current_project_name, + schema, + HeroVersionType, +) +from openpype.style import get_default_entity_icon_color +from openpype.tools.utils.models import TreeModel, Item + + +def walk_hierarchy(node): + """Recursively yield group node.""" + for child in node.children(): + if child.get("isGroupNode"): + yield child + + for _child in walk_hierarchy(child): + yield _child + + +class InventoryModel(TreeModel): + """The model for the inventory""" + + Columns = [ + "Name", + "version", + "count", + "family", + "group", + "loader", + "objectName", + "active_site", + "remote_site", + ] + active_site_col = Columns.index("active_site") + remote_site_col = Columns.index("remote_site") + + OUTDATED_COLOR = QtGui.QColor(235, 30, 30) + CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) + GRAYOUT_COLOR = QtGui.QColor(160, 160, 160) + + UniqueRole = QtCore.Qt.UserRole + 2 # unique label role + + def __init__(self, controller, parent=None): + super(InventoryModel, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + + self._controller = controller + + self._hierarchy_view = False + + self._default_icon_color = get_default_entity_icon_color() + + site_icons = self._controller.get_site_provider_icons() + + self._site_icons = { + provider: QtGui.QIcon(icon_path) + for provider, icon_path in site_icons.items() + } + + def outdated(self, item): + value = item.get("version") + if isinstance(value, HeroVersionType): + return False + + if item.get("version") == item.get("highest_version"): + return False + return True + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + + if role == QtCore.Qt.FontRole: + # Make top-level entries bold + if item.get("isGroupNode") or item.get("isNotSet"): # group-item + font = QtGui.QFont() + font.setBold(True) + return font + + if role == QtCore.Qt.ForegroundRole: + # Set the text color to the OUTDATED_COLOR when the + # collected version is not the same as the highest version + key = self.Columns[index.column()] + if key == "version": # version + if item.get("isGroupNode"): # group-item + if self.outdated(item): + return self.OUTDATED_COLOR + + if self._hierarchy_view: + # If current group is not outdated, check if any + # outdated children. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR + else: + + if self._hierarchy_view: + # Although this is not a group item, we still need + # to distinguish which one contain outdated child. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR.darker(150) + + return self.GRAYOUT_COLOR + + if key == "Name" and not item.get("isGroupNode"): + return self.GRAYOUT_COLOR + + # Add icons + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + # Override color + color = item.get("color", self._default_icon_color) + if item.get("isGroupNode"): # group-item + return qtawesome.icon("fa.folder", color=color) + if item.get("isNotSet"): + return qtawesome.icon("fa.exclamation-circle", color=color) + + return qtawesome.icon("fa.file-o", color=color) + + if index.column() == 3: + # Family icon + return item.get("familyIcon", None) + + column_name = self.Columns[index.column()] + + if column_name == "group" and item.get("group"): + return qtawesome.icon("fa.object-group", + color=get_default_entity_icon_color()) + + if item.get("isGroupNode"): + if column_name == "active_site": + provider = item.get("active_site_provider") + return self._site_icons.get(provider) + + if column_name == "remote_site": + provider = item.get("remote_site_provider") + return self._site_icons.get(provider) + + if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"): + column_name = self.Columns[index.column()] + progress = None + if column_name == "active_site": + progress = item.get("active_site_progress", 0) + elif column_name == "remote_site": + progress = item.get("remote_site_progress", 0) + if progress is not None: + return "{}%".format(max(progress, 0) * 100) + + if role == self.UniqueRole: + return item["representation"] + item.get("objectName", "") + + return super(InventoryModel, self).data(index, role) + + def set_hierarchy_view(self, state): + """Set whether to display subsets in hierarchy view.""" + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def refresh(self, selected=None, containers=None): + """Refresh the model""" + + # for debugging or testing, injecting items from outside + if containers is None: + containers = self._controller.get_containers() + + self.clear() + if not selected or not self._hierarchy_view: + self._add_containers(containers) + return + + # Filter by cherry-picked items + self._add_containers(( + container + for container in containers + if container["objectName"] in selected + )) + + def _add_containers(self, containers, parent=None): + """Add the items to the model. + + The items should be formatted similar to `api.ls()` returns, an item + is then represented as: + {"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma, + full/filename/of/loaded/filename_v001.ma], + "nodetype" : "reference", + "node": "referenceNode1"} + + Note: When performing an additional call to `add_items` it will *not* + group the new items with previously existing item groups of the + same type. + + Args: + containers (generator): Container items. + parent (Item, optional): Set this item as parent for the added + items when provided. Defaults to the root of the model. + + Returns: + node.Item: root node which has children added based on the data + """ + + project_name = get_current_project_name() + + self.beginResetModel() + + # Group by representation + grouped = defaultdict(lambda: {"containers": list()}) + for container in containers: + repre_id = container["representation"] + grouped[repre_id]["containers"].append(container) + + ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) = self._query_entities(project_name, set(grouped.keys())) + # Add to model + not_found = defaultdict(list) + not_found_ids = [] + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = repres_by_id.get(repre_id) + if not representation: + not_found["representation"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + version = versions_by_id.get(representation["parent"]) + if not version: + not_found["version"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + product = products_by_id.get(version["parent"]) + if not product: + not_found["product"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + folder = folders_by_id.get(product["parent"]) + if not folder: + not_found["folder"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + group_dict.update({ + "representation": representation, + "version": version, + "subset": product, + "asset": folder + }) + + for _repre_id in not_found_ids: + grouped.pop(_repre_id) + + for where, group_containers in not_found.items(): + # create the group header + group_node = Item() + name = "< NOT FOUND - {} >".format(where) + group_node["Name"] = name + group_node["representation"] = name + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = False + group_node["isNotSet"] = True + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + item_node["Name"] = container.get("objectName", "NO NAME") + item_node["isNotFound"] = True + self.add_child(item_node, parent=group_node) + + # TODO Use product icons + family_icon = qtawesome.icon( + "fa.folder", color="#0091B2" + ) + # Prepare site sync specific data + progress_by_id = self._controller.get_representations_site_progress( + set(grouped.keys()) + ) + sites_info = self._controller.get_sites_information() + + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = group_dict["representation"] + version = group_dict["version"] + subset = group_dict["subset"] + asset = group_dict["asset"] + + # Get the primary family + maj_version, _ = schema.get_schema_version(subset["schema"]) + if maj_version < 3: + src_doc = version + else: + src_doc = subset + + prim_family = src_doc["data"].get("family") + if not prim_family: + families = src_doc["data"].get("families") + if families: + prim_family = families[0] + + # Store the highest available version so the model can know + # whether current version is currently up-to-date. + highest_version = get_last_version_by_subset_id( + project_name, version["parent"] + ) + + # create the group header + group_node = Item() + group_node["Name"] = "{}_{}: ({})".format( + asset["name"], subset["name"], representation["name"] + ) + group_node["representation"] = repre_id + group_node["version"] = version["name"] + group_node["highest_version"] = highest_version["name"] + group_node["family"] = prim_family or "" + group_node["familyIcon"] = family_icon + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = True + group_node["group"] = subset["data"].get("subsetGroup") + + # Site sync specific data + progress = progress_by_id[repre_id] + group_node.update(sites_info) + group_node["active_site_progress"] = progress["active_site"] + group_node["remote_site_progress"] = progress["remote_site"] + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + + # store the current version on the item + item_node["version"] = version["name"] + + # Remapping namespace to item name. + # Noted that the name key is capital "N", by doing this, we + # can view namespace in GUI without changing container data. + item_node["Name"] = container["namespace"] + + self.add_child(item_node, parent=group_node) + + self.endResetModel() + + return self._root_item + + def _query_entities(self, project_name, repre_ids): + """Query entities for representations from containers. + + Returns: + tuple[dict, dict, dict, dict]: Representation, version, product + and folder documents by id. + """ + + repres_by_id = {} + versions_by_id = {} + products_by_id = {} + folders_by_id = {} + output = ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) + + filtered_repre_ids = set() + for repre_id in repre_ids: + # Filter out invalid representation ids + # NOTE: This is added because scenes from OpenPype did contain + # ObjectId from mongo. + try: + uuid.UUID(repre_id) + filtered_repre_ids.add(repre_id) + except ValueError: + continue + if not filtered_repre_ids: + return output + + repre_docs = get_representations(project_name, repre_ids) + repres_by_id.update({ + repre_doc["_id"]: repre_doc + for repre_doc in repre_docs + }) + version_ids = { + repre_doc["parent"] for repre_doc in repres_by_id.values() + } + if not version_ids: + return output + + version_docs = get_versions(project_name, version_ids, hero=True) + versions_by_id.update({ + version_doc["_id"]: version_doc + for version_doc in version_docs + }) + hero_versions_by_subversion_id = collections.defaultdict(list) + for version_doc in versions_by_id.values(): + if version_doc["type"] != "hero_version": + continue + subversion = version_doc["version_id"] + hero_versions_by_subversion_id[subversion].append(version_doc) + + if hero_versions_by_subversion_id: + subversion_ids = set( + hero_versions_by_subversion_id.keys() + ) + subversion_docs = get_versions(project_name, subversion_ids) + for subversion_doc in subversion_docs: + subversion_id = subversion_doc["_id"] + subversion_ids.discard(subversion_id) + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + version_doc["name"] = HeroVersionType( + subversion_doc["name"] + ) + version_doc["data"] = copy.deepcopy( + subversion_doc["data"] + ) + + for subversion_id in subversion_ids: + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + versions_by_id.pop(version_doc["_id"]) + + product_ids = { + version_doc["parent"] + for version_doc in versions_by_id.values() + } + if not product_ids: + return output + product_docs = get_subsets(project_name, product_ids) + products_by_id.update({ + product_doc["_id"]: product_doc + for product_doc in product_docs + }) + folder_ids = { + product_doc["parent"] + for product_doc in products_by_id.values() + } + if not folder_ids: + return output + + folder_docs = get_assets(project_name, folder_ids) + folders_by_id.update({ + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + }) + return output + + +class FilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(FilterProxyModel, self).__init__(*args, **kwargs) + self._filter_outdated = False + self._hierarchy_view = False + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + + # Always allow bottom entries (individual containers), since their + # parent group hidden if it wouldn't have been validated. + rows = model.rowCount(source_index) + if not rows: + return True + + # Filter by regex + if hasattr(self, "filterRegExp"): + regex = self.filterRegExp() + else: + regex = self.filterRegularExpression() + pattern = regex.pattern() + if pattern: + pattern = re.escape(pattern) + + if not self._matches(row, parent, pattern): + return False + + if self._filter_outdated: + # When filtering to outdated we filter the up to date entries + # thus we "allow" them when they are outdated + if not self._is_outdated(row, parent): + return False + + return True + + def set_filter_outdated(self, state): + """Set whether to show the outdated entries only.""" + state = bool(state) + + if state != self._filter_outdated: + self._filter_outdated = bool(state) + self.invalidateFilter() + + def set_hierarchy_view(self, state): + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def _is_outdated(self, row, parent): + """Return whether row is outdated. + + A row is considered outdated if it has "version" and "highest_version" + data and in the internal data structure, and they are not of an + equal value. + + """ + def outdated(node): + version = node.get("version", None) + highest = node.get("highest_version", None) + + # Always allow indices that have no version data at all + if version is None and highest is None: + return True + + # If either a version or highest is present but not the other + # consider the item invalid. + if not self._hierarchy_view: + # Skip this check if in hierarchy view, or the child item + # node will be hidden even it's actually outdated. + if version is None or highest is None: + return False + return version != highest + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + + # The scene contents are grouped by "representation", e.g. the same + # "representation" loaded twice is grouped under the same header. + # Since the version check filters these parent groups we skip that + # check for the individual children. + has_parent = index.parent().isValid() + if has_parent and not self._hierarchy_view: + return True + + # Filter to those that have the different version numbers + node = index.internalPointer() + if outdated(node): + return True + + if self._hierarchy_view: + for _node in walk_hierarchy(node): + if outdated(_node): + return True + + return False + + def _matches(self, row, parent, pattern): + """Return whether row matches regex pattern. + + Args: + row (int): row number in model + parent (QtCore.QModelIndex): parent index + pattern (regex.pattern): pattern to check for in key + + Returns: + bool + + """ + model = self.sourceModel() + column = self.filterKeyColumn() + role = self.filterRole() + + def matches(row, parent, pattern): + index = model.index(row, column, parent) + key = model.data(index, role) + if re.search(pattern, key, re.IGNORECASE): + return True + + if matches(row, parent, pattern): + return True + + # Also allow if any of the children matches + source_index = model.index(row, column, parent) + rows = model.rowCount(source_index) + + if any( + matches(idx, source_index, pattern) + for idx in range(rows) + ): + return True + + if not self._hierarchy_view: + return False + + for idx in range(rows): + child_index = model.index(idx, column, source_index) + child_rows = model.rowCount(child_index) + return any( + self._matches(child_idx, child_index, pattern) + for child_idx in range(child_rows) + ) + + return True diff --git a/openpype/tools/ayon_sceneinventory/models/__init__.py b/openpype/tools/ayon_sceneinventory/models/__init__.py new file mode 100644 index 0000000000..c861d3c1a0 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/__init__.py @@ -0,0 +1,6 @@ +from .site_sync import SiteSyncModel + + +__all__ = ( + "SiteSyncModel", +) diff --git a/openpype/tools/ayon_sceneinventory/models/site_sync.py b/openpype/tools/ayon_sceneinventory/models/site_sync.py new file mode 100644 index 0000000000..b8c9443230 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/site_sync.py @@ -0,0 +1,176 @@ +from openpype.client import get_representations +from openpype.modules import ModulesManager + +NOT_SET = object() + + +class SiteSyncModel: + def __init__(self, controller): + self._controller = controller + + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def reset(self): + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def is_sync_server_enabled(self): + """Site sync is enabled. + + Returns: + bool: Is enabled or not. + """ + + self._cache_sync_server_module() + return self._sync_server_enabled + + def get_site_provider_icons(self): + """Icon paths per provider. + + Returns: + dict[str, str]: Path by provider name. + """ + + site_sync = self._get_sync_server_module() + if site_sync is None: + return {} + return site_sync.get_site_icons() + + def get_sites_information(self): + return { + "active_site": self._get_active_site(), + "active_site_provider": self._get_active_site_provider(), + "remote_site": self._get_remote_site(), + "remote_site_provider": self._get_remote_site_provider() + } + + def get_representations_site_progress(self, representation_ids): + """Get progress of representations sync.""" + + representation_ids = set(representation_ids) + output = { + repre_id: { + "active_site": 0, + "remote_site": 0, + } + for repre_id in representation_ids + } + if not self.is_sync_server_enabled(): + return output + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + repre_docs = get_representations(project_name, representation_ids) + active_site = self._get_active_site() + remote_site = self._get_remote_site() + + for repre_doc in repre_docs: + repre_output = output[repre_doc["_id"]] + result = site_sync.get_progress_for_repre( + repre_doc, active_site, remote_site + ) + repre_output["active_site"] = result[active_site] + repre_output["remote_site"] = result[remote_site] + + return output + + def resync_representations(self, representation_ids, site_type): + """ + + Args: + representation_ids (Iterable[str]): Representation ids. + site_type (Literal[active_site, remote_site]): Site type. + """ + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + active_site = self._get_active_site() + remote_site = self._get_remote_site() + progress = self.get_representations_site_progress( + representation_ids + ) + for repre_id in representation_ids: + repre_progress = progress.get(repre_id) + if not repre_progress: + continue + + if site_type == "active_site": + # check opposite from added site, must be 1 or unable to sync + check_progress = repre_progress["remote_site"] + site = active_site + else: + check_progress = repre_progress["active_site"] + site = remote_site + + if check_progress == 1: + site_sync.add_site( + project_name, repre_id, site, force=True + ) + + def _get_sync_server_module(self): + self._cache_sync_server_module() + return self._sync_server_module + + def _cache_sync_server_module(self): + if self._sync_server_module is not NOT_SET: + return self._sync_server_module + manager = ModulesManager() + site_sync = manager.modules_by_name.get("sync_server") + sync_enabled = site_sync is not None and site_sync.enabled + self._sync_server_module = site_sync + self._sync_server_enabled = sync_enabled + + def _get_active_site(self): + if self._active_site is NOT_SET: + self._cache_sites() + return self._active_site + + def _get_remote_site(self): + if self._remote_site is NOT_SET: + self._cache_sites() + return self._remote_site + + def _get_active_site_provider(self): + if self._active_site_provider is NOT_SET: + self._cache_sites() + return self._active_site_provider + + def _get_remote_site_provider(self): + if self._remote_site_provider is NOT_SET: + self._cache_sites() + return self._remote_site_provider + + def _cache_sites(self): + site_sync = self._get_sync_server_module() + active_site = None + remote_site = None + active_site_provider = None + remote_site_provider = None + if site_sync is not None: + project_name = self._controller.get_current_project_name() + active_site = site_sync.get_active_site(project_name) + remote_site = site_sync.get_remote_site(project_name) + active_site_provider = "studio" + remote_site_provider = "studio" + if active_site != "studio": + active_site_provider = site_sync.get_active_provider( + project_name, active_site + ) + if remote_site != "studio": + remote_site_provider = site_sync.get_active_provider( + project_name, remote_site + ) + + self._active_site = active_site + self._remote_site = remote_site + self._active_site_provider = active_site_provider + self._remote_site_provider = remote_site_provider diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py new file mode 100644 index 0000000000..4c07832829 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py @@ -0,0 +1,6 @@ +from .dialog import SwitchAssetDialog + + +__all__ = ( + "SwitchAssetDialog", +) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py new file mode 100644 index 0000000000..2ebed7f89b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py @@ -0,0 +1,1333 @@ +import collections +import logging + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_assets, + get_subset_by_name, + get_subsets, + get_versions, + get_hero_versions, + get_last_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + switch_container, + get_repres_contexts, + loaders_from_repre_context, + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError +) + +from .widgets import ( + ButtonWithMenu, + SearchComboBox +) +from .folders_input import FoldersField + +log = logging.getLogger("SwitchAssetDialog") + + +class ValidationState: + def __init__(self): + self.folder_ok = True + self.product_ok = True + self.repre_ok = True + + @property + def all_ok(self): + return ( + self.folder_ok + and self.product_ok + and self.repre_ok + ) + + +class SwitchAssetDialog(QtWidgets.QDialog): + """Widget to support asset switching""" + + MIN_WIDTH = 550 + + switched = QtCore.Signal() + + def __init__(self, controller, parent=None, items=None): + super(SwitchAssetDialog, self).__init__(parent) + + self.setWindowTitle("Switch selected items ...") + + # Force and keep focus dialog + self.setModal(True) + + folders_field = FoldersField(controller, self) + products_combox = SearchComboBox(self) + repres_combobox = SearchComboBox(self) + + products_combox.set_placeholder("") + repres_combobox.set_placeholder("") + + folder_label = QtWidgets.QLabel(self) + product_label = QtWidgets.QLabel(self) + repre_label = QtWidgets.QLabel(self) + + current_folder_btn = QtWidgets.QPushButton("Use current folder", self) + + accept_icon = qtawesome.icon("fa.check", color="white") + accept_btn = ButtonWithMenu(self) + accept_btn.setIcon(accept_icon) + + main_layout = QtWidgets.QGridLayout(self) + # Folder column + main_layout.addWidget(current_folder_btn, 0, 0) + main_layout.addWidget(folders_field, 1, 0) + main_layout.addWidget(folder_label, 2, 0) + # Product column + main_layout.addWidget(products_combox, 1, 1) + main_layout.addWidget(product_label, 2, 1) + # Representation column + main_layout.addWidget(repres_combobox, 1, 2) + main_layout.addWidget(repre_label, 2, 2) + # Btn column + main_layout.addWidget(accept_btn, 1, 3) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + main_layout.setColumnStretch(2, 1) + main_layout.setColumnStretch(3, 0) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + show_timer.timeout.connect(self._on_show_timer) + folders_field.value_changed.connect( + self._combobox_value_changed + ) + products_combox.currentIndexChanged.connect( + self._combobox_value_changed + ) + repres_combobox.currentIndexChanged.connect( + self._combobox_value_changed + ) + accept_btn.clicked.connect(self._on_accept) + current_folder_btn.clicked.connect(self._on_current_folder) + + self._show_timer = show_timer + self._show_counter = 0 + + self._current_folder_btn = current_folder_btn + + self._folders_field = folders_field + self._products_combox = products_combox + self._representations_box = repres_combobox + + self._folder_label = folder_label + self._product_label = product_label + self._repre_label = repre_label + + self._accept_btn = accept_btn + + self.setMinimumWidth(self.MIN_WIDTH) + + # Set default focus to accept button so you don't directly type in + # first asset field, this also allows to see the placeholder value. + accept_btn.setFocus() + + self._folder_docs_by_id = {} + self._product_docs_by_id = {} + self._version_docs_by_id = {} + self._repre_docs_by_id = {} + + self._missing_folder_ids = set() + self._missing_product_ids = set() + self._missing_version_ids = set() + self._missing_repre_ids = set() + self._missing_docs = False + + self._inactive_folder_ids = set() + self._inactive_product_ids = set() + self._inactive_repre_ids = set() + + self._init_folder_id = None + self._init_product_name = None + self._init_repre_name = None + + self._fill_check = False + + self._project_name = controller.get_current_project_name() + self._folder_id = controller.get_current_folder_id() + + self._current_folder_btn.setEnabled(self._folder_id is not None) + + self._controller = controller + + self._items = items + self._prepare_content_data() + + def showEvent(self, event): + super(SwitchAssetDialog, self).showEvent(event) + self._show_timer.start() + + def refresh(self, init_refresh=False): + """Build the need comboboxes with content""" + if not self._fill_check and not init_refresh: + return + + self._fill_check = False + + validation_state = ValidationState() + self._folders_field.refresh() + # Set other comboboxes to empty if any document is missing or + # any folder of loaded representations is archived. + self._is_folder_ok(validation_state) + if validation_state.folder_ok: + product_values = self._get_product_box_values() + self._fill_combobox(product_values, "product") + self._is_product_ok(validation_state) + + if validation_state.folder_ok and validation_state.product_ok: + repre_values = sorted(self._representations_box_values()) + self._fill_combobox(repre_values, "repre") + self._is_repre_ok(validation_state) + + # Fill comboboxes with values + self.set_labels() + + self.apply_validations(validation_state) + + self._build_loaders_menu() + + if init_refresh: + # pre select context if possible + self._folders_field.set_selected_item(self._init_folder_id) + self._products_combox.set_valid_value(self._init_product_name) + self._representations_box.set_valid_value(self._init_repre_name) + + self._fill_check = True + + def set_labels(self): + folder_label = self._folders_field.get_selected_folder_label() + product_label = self._products_combox.get_valid_value() + repre_label = self._representations_box.get_valid_value() + + default = "*No changes" + self._folder_label.setText(folder_label or default) + self._product_label.setText(product_label or default) + self._repre_label.setText(repre_label or default) + + def apply_validations(self, validation_state): + error_msg = "*Please select" + error_sheet = "border: 1px solid red;" + + product_sheet = None + repre_sheet = None + accept_state = "" + if validation_state.folder_ok is False: + self._folder_label.setText(error_msg) + elif validation_state.product_ok is False: + product_sheet = error_sheet + self._product_label.setText(error_msg) + elif validation_state.repre_ok is False: + repre_sheet = error_sheet + self._repre_label.setText(error_msg) + + if validation_state.all_ok: + accept_state = "1" + + self._folders_field.set_valid(validation_state.folder_ok) + self._products_combox.setStyleSheet(product_sheet or "") + self._representations_box.setStyleSheet(repre_sheet or "") + + self._accept_btn.setEnabled(validation_state.all_ok) + self._set_style_property(self._accept_btn, "state", accept_state) + + def find_last_versions(self, product_ids): + project_name = self._project_name + return get_last_versions( + project_name, + subset_ids=product_ids, + fields=["_id", "parent", "type"] + ) + + def _on_show_timer(self): + if self._show_counter == 2: + self._show_timer.stop() + self.refresh(True) + else: + self._show_counter += 1 + + def _prepare_content_data(self): + repre_ids = { + item["representation"] + for item in self._items + } + + project_name = self._project_name + repres = list(get_representations( + project_name, + representation_ids=repre_ids, + archived=True, + )) + repres_by_id = {str(repre["_id"]): repre for repre in repres} + + content_repre_docs_by_id = {} + inactive_repre_ids = set() + missing_repre_ids = set() + version_ids = set() + for repre_id in repre_ids: + repre_doc = repres_by_id.get(repre_id) + if repre_doc is None: + missing_repre_ids.add(repre_id) + elif repres_by_id[repre_id]["type"] == "archived_representation": + inactive_repre_ids.add(repre_id) + version_ids.add(repre_doc["parent"]) + else: + content_repre_docs_by_id[repre_id] = repre_doc + version_ids.add(repre_doc["parent"]) + + version_docs = get_versions( + project_name, + version_ids=version_ids, + hero=True + ) + content_version_docs_by_id = {} + for version_doc in version_docs: + version_id = version_doc["_id"] + content_version_docs_by_id[version_id] = version_doc + + missing_version_ids = set() + product_ids = set() + for version_id in version_ids: + version_doc = content_version_docs_by_id.get(version_id) + if version_doc is None: + missing_version_ids.add(version_id) + else: + product_ids.add(version_doc["parent"]) + + product_docs = get_subsets( + project_name, subset_ids=product_ids, archived=True + ) + product_docs_by_id = {sub["_id"]: sub for sub in product_docs} + + folder_ids = set() + inactive_product_ids = set() + missing_product_ids = set() + content_product_docs_by_id = {} + for product_id in product_ids: + product_doc = product_docs_by_id.get(product_id) + if product_doc is None: + missing_product_ids.add(product_id) + elif product_doc["type"] == "archived_subset": + folder_ids.add(product_doc["parent"]) + inactive_product_ids.add(product_id) + else: + folder_ids.add(product_doc["parent"]) + content_product_docs_by_id[product_id] = product_doc + + folder_docs = get_assets( + project_name, asset_ids=folder_ids, archived=True + ) + folder_docs_by_id = { + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + } + + missing_folder_ids = set() + inactive_folder_ids = set() + content_folder_docs_by_id = {} + for folder_id in folder_ids: + folder_doc = folder_docs_by_id.get(folder_id) + if folder_doc is None: + missing_folder_ids.add(folder_id) + elif folder_doc["type"] == "archived_asset": + inactive_folder_ids.add(folder_id) + else: + content_folder_docs_by_id[folder_id] = folder_doc + + # stash context values, works only for single representation + init_folder_id = None + init_product_name = None + init_repre_name = None + if len(repres) == 1: + init_repre_doc = repres[0] + init_version_doc = content_version_docs_by_id.get( + init_repre_doc["parent"]) + init_product_doc = None + init_folder_doc = None + if init_version_doc: + init_product_doc = content_product_docs_by_id.get( + init_version_doc["parent"] + ) + if init_product_doc: + init_folder_doc = content_folder_docs_by_id.get( + init_product_doc["parent"] + ) + if init_folder_doc: + init_repre_name = init_repre_doc["name"] + init_product_name = init_product_doc["name"] + init_folder_id = init_folder_doc["_id"] + + self._init_folder_id = init_folder_id + self._init_product_name = init_product_name + self._init_repre_name = init_repre_name + + self._folder_docs_by_id = content_folder_docs_by_id + self._product_docs_by_id = content_product_docs_by_id + self._version_docs_by_id = content_version_docs_by_id + self._repre_docs_by_id = content_repre_docs_by_id + + self._missing_folder_ids = missing_folder_ids + self._missing_product_ids = missing_product_ids + self._missing_version_ids = missing_version_ids + self._missing_repre_ids = missing_repre_ids + self._missing_docs = ( + bool(missing_folder_ids) + or bool(missing_version_ids) + or bool(missing_product_ids) + or bool(missing_repre_ids) + ) + + self._inactive_folder_ids = inactive_folder_ids + self._inactive_product_ids = inactive_product_ids + self._inactive_repre_ids = inactive_repre_ids + + def _combobox_value_changed(self, *args, **kwargs): + self.refresh() + + def _build_loaders_menu(self): + repre_ids = self._get_current_output_repre_ids() + loaders = self._get_loaders(repre_ids) + # Get and destroy the action group + self._accept_btn.clear_actions() + + if not loaders: + return + + # Build new action group + group = QtWidgets.QActionGroup(self._accept_btn) + + for loader in loaders: + # Label + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + + action = group.addAction(label) + # action = QtWidgets.QAction(label) + action.setData(loader) + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + action.setIcon(qtawesome.icon(key, color=color)) + + except Exception as exc: + print("Unable to set icon for loader {}: {}".format( + loader, str(exc) + )) + + self._accept_btn.add_action(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + loader_plugin = action.data() + self._trigger_switch(loader_plugin) + + def _get_loaders(self, repre_ids): + repre_contexts = None + if repre_ids: + repre_contexts = get_repres_contexts(repre_ids) + + if not repre_contexts: + return list() + + available_loaders = [] + for loader_plugin in discover_loader_plugins(): + # Skip loaders without switch method + if not hasattr(loader_plugin, "switch"): + continue + + # Skip utility loaders + if ( + hasattr(loader_plugin, "is_utility") + and loader_plugin.is_utility + ): + continue + available_loaders.append(loader_plugin) + + loaders = None + for repre_context in repre_contexts.values(): + _loaders = set(loaders_from_repre_context( + available_loaders, repre_context + )) + if loaders is None: + loaders = _loaders + else: + loaders = _loaders.intersection(loaders) + + if not loaders: + break + + if loaders is None: + loaders = [] + else: + loaders = list(loaders) + + return loaders + + def _fill_combobox(self, values, combobox_type): + if combobox_type == "product": + combobox_widget = self._products_combox + elif combobox_type == "repre": + combobox_widget = self._representations_box + else: + return + selected_value = combobox_widget.get_valid_value() + + # Fill combobox + if values is not None: + combobox_widget.populate(list(sorted(values))) + if selected_value and selected_value in values: + index = None + for idx in range(combobox_widget.count()): + if selected_value == str(combobox_widget.itemText(idx)): + index = idx + break + if index is not None: + combobox_widget.setCurrentIndex(index) + + def _set_style_property(self, widget, name, value): + cur_value = widget.property(name) + if cur_value == value: + return + widget.setProperty(name, value) + widget.style().polish(widget) + + def _get_current_output_repre_ids(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + selected_repre = self._representations_box.currentText() + + # Nothing is selected + # [ ] [ ] [ ] + if ( + not selected_folder_id + and not selected_product_name + and not selected_repre + ): + return list(self._repre_docs_by_id.keys()) + + # Everything is selected + # [x] [x] [x] + if selected_folder_id and selected_product_name and selected_repre: + return self._get_current_output_repre_ids_xxx( + selected_folder_id, selected_product_name, selected_repre + ) + + # [x] [x] [ ] + # If folder and product is selected + if selected_folder_id and selected_product_name: + return self._get_current_output_repre_ids_xxo( + selected_folder_id, selected_product_name + ) + + # [x] [ ] [x] + # If folder and repre is selected + if selected_folder_id and selected_repre: + return self._get_current_output_repre_ids_xox( + selected_folder_id, selected_repre + ) + + # [x] [ ] [ ] + # If folder and product is selected + if selected_folder_id: + return self._get_current_output_repre_ids_xoo(selected_folder_id) + + # [ ] [x] [x] + if selected_product_name and selected_repre: + return self._get_current_output_repre_ids_oxx( + selected_product_name, selected_repre + ) + + # [ ] [x] [ ] + if selected_product_name: + return self._get_current_output_repre_ids_oxo( + selected_product_name + ) + + # [ ] [ ] [x] + return self._get_current_output_repre_ids_oox(selected_repre) + + def _get_current_output_repre_ids_xxx( + self, folder_id, selected_product_name, selected_repre + ): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + selected_product_name, + folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + if not version_doc: + return [] + + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xxo(self, folder_id, product_name): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + product_name, + folder_id, + fields=["_id"] + ) + if not product_doc: + return [] + + repre_names = set() + for repre_doc in self._repre_docs_by_id.values(): + repre_names.add(repre_doc["name"]) + + # TODO where to take version ids? + version_ids = [] + repre_docs = get_representations( + project_name, + representation_names=repre_names, + version_ids=version_ids, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xox(self, folder_id, selected_repre): + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=product_names, + fields=["_id", "name"] + ) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_docs = get_representations( + project_name, + version_ids=last_version_id_by_product_name.values(), + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xoo(self, folder_id): + project_name = self._project_name + repres_by_product_name = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + product_name = product_doc["name"] + repres_by_product_name[product_name].add(repre_doc["name"]) + + product_docs = list(get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=repres_by_product_name.keys(), + fields=["_id", "name"] + )) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_names_by_version_id = {} + for product_name, repre_names in repres_by_product_name.items(): + version_id = last_version_id_by_product_name.get(product_name) + # This should not happen but why to crash? + if version_id is not None: + repre_names_by_version_id[version_id] = list(repre_names) + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxx( + self, product_name, selected_repre + ): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id"] + ) + product_ids = [product_doc["_id"] for product_doc in product_docs] + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_ids = [ + last_version["_id"] + for last_version in last_versions_by_product_id.values() + ] + repre_docs = get_representations( + project_name, + version_ids=last_version_ids, + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxo(self, product_name): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id", "parent"] + ) + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + if not product_docs: + return list() + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_doc = self._folder_docs_by_id[product_doc["parent"]] + folder_id = folder_doc["_id"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + repre_names_by_version_id = {} + for last_version_id, product_id in product_id_by_version_id.items(): + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + repre_names = repre_names_by_folder_id.get(folder_id) + if not repre_names: + continue + repre_names_by_version_id[last_version_id] = repre_names + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oox(self, selected_repre): + project_name = self._project_name + repre_docs = get_representations( + project_name, + representation_names=[selected_repre], + version_ids=self._version_docs_by_id.keys(), + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_product_box_values(self): + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + if selected_folder_id: + folder_ids = [selected_folder_id] + else: + folder_ids = list(self._folder_docs_by_id.keys()) + + product_docs = get_subsets( + project_name, + asset_ids=folder_ids, + fields=["parent", "name"] + ) + + product_names_by_parent_id = collections.defaultdict(set) + for product_doc in product_docs: + product_names_by_parent_id[product_doc["parent"]].add( + product_doc["name"] + ) + + possible_product_names = None + for product_names in product_names_by_parent_id.values(): + if possible_product_names is None: + possible_product_names = product_names + else: + possible_product_names = possible_product_names.intersection( + product_names) + + if not possible_product_names: + break + + if not possible_product_names: + return [] + return list(possible_product_names) + + def _representations_box_values(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + + # If nothing is selected + # [ ] [ ] [?] + if not selected_folder_id and not selected_product_name: + # Find all representations of selection's products + possible_repres = get_representations( + project_name, + version_ids=self._version_docs_by_id.keys(), + fields=["parent", "name"] + ) + + possible_repres_by_parent = collections.defaultdict(set) + for repre in possible_repres: + possible_repres_by_parent[repre["parent"]].add(repre["name"]) + + output_repres = None + for repre_names in possible_repres_by_parent.values(): + if output_repres is None: + output_repres = repre_names + else: + output_repres = (output_repres & repre_names) + + if not output_repres: + break + + return list(output_repres or list()) + + # [x] [x] [?] + if selected_folder_id and selected_product_name: + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + fields=["name"] + ) + return [ + repre_doc["name"] + for repre_doc in repre_docs + ] + + # [x] [ ] [?] + # If only folder is selected + if selected_folder_id: + # Filter products by names from content + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + product_docs = get_subsets( + project_name, + asset_ids=[selected_folder_id], + subset_names=product_names, + fields=["_id"] + ) + product_ids = { + product_doc["_id"] + for product_doc in product_docs + } + if not product_ids: + return list() + + last_versions_by_product_id = self.find_last_versions(product_ids) + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list(get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + )) + if not repre_docs: + return list() + + repre_names_by_parent = collections.defaultdict(set) + for repre_doc in repre_docs: + repre_names_by_parent[repre_doc["parent"]].add( + repre_doc["name"] + ) + + available_repres = None + for repre_names in repre_names_by_parent.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + # [ ] [x] [?] + product_docs = list(get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "parent"] + )) + if not product_docs: + return list() + + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list( + get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + ) + if not repre_docs: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + available_repres = None + for repre_names in repre_names_by_folder_id.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + def _is_folder_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + if ( + selected_folder_id is None + and (self._missing_docs or self._inactive_folder_ids) + ): + validation_state.folder_ok = False + + def _is_product_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + + # [?] [x] [?] + # If product is selected then must be ok + if selected_product_name is not None: + return + + # [ ] [ ] [?] + if selected_folder_id is None: + # If there were archived products and folder is not selected + if self._inactive_product_ids: + validation_state.product_ok = False + return + + # [x] [ ] [?] + project_name = self._project_name + product_docs = get_subsets( + project_name, asset_ids=[selected_folder_id], fields=["name"] + ) + + product_names = set( + product_doc["name"] + for product_doc in product_docs + ) + + for product_doc in self._product_docs_by_id.values(): + if product_doc["name"] not in product_names: + validation_state.product_ok = False + break + + def _is_repre_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_repre = self._representations_box.get_valid_value() + + # [?] [?] [x] + # If product is selected then must be ok + if selected_repre is not None: + return + + # [ ] [ ] [ ] + if selected_folder_id is None and selected_product_name is None: + if ( + self._inactive_repre_ids + or self._missing_version_ids + or self._missing_repre_ids + ): + validation_state.repre_ok = False + return + + # [x] [x] [ ] + project_name = self._project_name + if ( + selected_folder_id is not None + and selected_product_name is not None + ): + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + last_version = last_versions_by_product_id.get(product_id) + if not last_version: + validation_state.repre_ok = False + return + + repre_docs = get_representations( + project_name, + version_ids=[last_version["_id"]], + fields=["name"] + ) + + repre_names = set( + repre_doc["name"] + for repre_doc in repre_docs + ) + for repre_doc in self._repre_docs_by_id.values(): + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [x] [ ] [ ] + if selected_folder_id is not None: + product_docs = list(get_subsets( + project_name, + asset_ids=[selected_folder_id], + fields=["_id", "name"] + )) + + product_name_by_id = {} + product_ids = set() + for product_doc in product_docs: + product_id = product_doc["_id"] + product_ids.add(product_id) + product_name_by_id[product_id] = product_doc["name"] + + last_versions_by_product_id = self.find_last_versions(product_ids) + + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_product_name = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + product_name = product_name_by_id[product_id] + repres_by_product_name[product_name].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + repre_names = repres_by_product_name[product_doc["name"]] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [ ] [x] [ ] + # Product documents + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "name", "parent"] + ) + product_docs_by_id = {} + for product_doc in product_docs: + product_docs_by_id[product_doc["_id"]] = product_doc + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repres_by_folder_id[folder_id].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_id = product_doc["parent"] + repre_names = repres_by_folder_id[folder_id] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + + def _on_current_folder(self): + # Set initial folder as current. + folder_id = self._controller.get_current_folder_id() + if not folder_id: + return + + selected_folder_id = self._folders_field.get_selected_folder_id() + if folder_id == selected_folder_id: + return + + self._folders_field.set_selected_item(folder_id) + self._combobox_value_changed() + + def _on_accept(self): + self._trigger_switch() + + def _trigger_switch(self, loader=None): + # Use None when not a valid value or when placeholder value + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_representation = self._representations_box.get_valid_value() + + project_name = self._project_name + if selected_folder_id: + folder_ids = {selected_folder_id} + else: + folder_ids = set(self._folder_docs_by_id.keys()) + + product_names = None + if selected_product_name: + product_names = [selected_product_name] + + product_docs = list(get_subsets( + project_name, + subset_names=product_names, + asset_ids=folder_ids + )) + product_ids = set() + product_docs_by_parent_and_name = collections.defaultdict(dict) + for product_doc in product_docs: + product_ids.add(product_doc["_id"]) + folder_id = product_doc["parent"] + name = product_doc["name"] + product_docs_by_parent_and_name[folder_id][name] = product_doc + + # versions + _version_docs = get_versions(project_name, subset_ids=product_ids) + version_docs = list(reversed( + sorted(_version_docs, key=lambda item: item["name"]) + )) + + hero_version_docs = list(get_hero_versions( + project_name, subset_ids=product_ids + )) + + version_ids = set() + version_docs_by_parent_id = {} + for version_doc in version_docs: + parent_id = version_doc["parent"] + if parent_id not in version_docs_by_parent_id: + version_ids.add(version_doc["_id"]) + version_docs_by_parent_id[parent_id] = version_doc + + hero_version_docs_by_parent_id = {} + for hero_version_doc in hero_version_docs: + version_ids.add(hero_version_doc["_id"]) + parent_id = hero_version_doc["parent"] + hero_version_docs_by_parent_id[parent_id] = hero_version_doc + + repre_docs = get_representations( + project_name, version_ids=version_ids + ) + repre_docs_by_parent_id_by_name = collections.defaultdict(dict) + for repre_doc in repre_docs: + parent_id = repre_doc["parent"] + name = repre_doc["name"] + repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc + + for container in self._items: + self._switch_container( + container, + loader, + selected_folder_id, + selected_product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ) + + self.switched.emit() + + self.close() + + def _switch_container( + self, + container, + loader, + selected_folder_id, + product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ): + container_repre_id = container["representation"] + container_repre = self._repre_docs_by_id[container_repre_id] + container_repre_name = container_repre["name"] + container_version_id = container_repre["parent"] + + container_version = self._version_docs_by_id[container_version_id] + + container_product_id = container_version["parent"] + container_product = self._product_docs_by_id[container_product_id] + + if selected_folder_id: + folder_id = selected_folder_id + else: + folder_id = container_product["parent"] + + products_by_name = product_docs_by_parent_and_name[folder_id] + if product_name: + product_doc = products_by_name[product_name] + else: + product_doc = products_by_name[container_product["name"]] + + repre_doc = None + product_id = product_doc["_id"] + if container_version["type"] == "hero_version": + hero_version = hero_version_docs_by_parent_id.get( + product_id + ) + if hero_version: + _repres = repre_docs_by_parent_id_by_name.get( + hero_version["_id"] + ) + if selected_representation: + repre_doc = _repres.get(selected_representation) + else: + repre_doc = _repres.get(container_repre_name) + + if not repre_doc: + version_doc = version_docs_by_parent_id[product_id] + version_id = version_doc["_id"] + repres_by_name = repre_docs_by_parent_id_by_name[version_id] + if selected_representation: + repre_doc = repres_by_name[selected_representation] + else: + repre_doc = repres_by_name[container_repre_name] + + error = None + try: + switch_container(container, repre_doc, loader) + except ( + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError, + ) as exc: + error = str(exc) + except Exception: + error = ( + "Switch asset failed. " + "Search console log for more details." + ) + if error is not None: + log.warning(( + "Couldn't switch asset." + "See traceback for more information." + ), exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Switch asset failed") + dialog.setText(error) + dialog.exec_() diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py new file mode 100644 index 0000000000..699c62371a --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py @@ -0,0 +1,307 @@ +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.tools.utils import ( + PlaceholderLineEdit, + BaseClickableFrame, + set_style_property, +) +from openpype.tools.ayon_utils.widgets import FoldersWidget + +NOT_SET = object() + + +class ClickableLineEdit(QtWidgets.QLineEdit): + """QLineEdit capturing left mouse click. + + Triggers `clicked` signal on mouse click. + """ + clicked = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(ClickableLineEdit, self).__init__(*args, **kwargs) + self.setReadOnly(True) + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + event.accept() + + def mouseMoveEvent(self, event): + event.accept() + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + event.accept() + + def mouseDoubleClickEvent(self, event): + event.accept() + + +class ControllerWrap: + def __init__(self, controller): + self._controller = controller + self._selected_folder_id = None + + def emit_event(self, *args, **kwargs): + self._controller.emit_event(*args, **kwargs) + + def register_event_callback(self, *args, **kwargs): + self._controller.register_event_callback(*args, **kwargs) + + def get_current_project_name(self): + return self._controller.get_current_project_name() + + def get_folder_items(self, *args, **kwargs): + return self._controller.get_folder_items(*args, **kwargs) + + def set_selected_folder(self, folder_id): + self._selected_folder_id = folder_id + + def get_selected_folder_id(self): + return self._selected_folder_id + + +class FoldersDialog(QtWidgets.QDialog): + """Dialog to select asset for a context of instance.""" + + def __init__(self, controller, parent): + super(FoldersDialog, self).__init__(parent) + self.setWindowTitle("Select folder") + + filter_input = PlaceholderLineEdit(self) + filter_input.setPlaceholderText("Filter folders..") + + controller_wrap = ControllerWrap(controller) + folders_widget = FoldersWidget(controller_wrap, self) + folders_widget.set_deselectable(True) + + ok_btn = QtWidgets.QPushButton("OK", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(filter_input, 0) + layout.addWidget(folders_widget, 1) + layout.addLayout(btns_layout, 0) + + folders_widget.double_clicked.connect(self._on_ok_clicked) + folders_widget.refreshed.connect(self._on_folders_refresh) + filter_input.textChanged.connect(self._on_filter_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._filter_input = filter_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._folders_widget = folders_widget + self._controller_wrap = controller_wrap + + # Set selected folder only when user confirms the dialog + self._selected_folder_id = None + self._selected_folder_label = None + + self._folder_id_to_select = NOT_SET + + self._first_show = True + self._default_height = 500 + + def showEvent(self, event): + """Refresh asset model on show.""" + super(FoldersDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + + def refresh(self): + project_name = self._controller_wrap.get_current_project_name() + self._folders_widget.set_project_name(project_name) + + def _on_first_show(self): + center = self.rect().center() + size = self.size() + size.setHeight(self._default_height) + + self.resize(size) + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + + def _on_folders_refresh(self): + if self._folder_id_to_select is NOT_SET: + return + self._folders_widget.set_selected_folder(self._folder_id_to_select) + self._folder_id_to_select = NOT_SET + + def _on_filter_change(self, text): + """Trigger change of filter of folders.""" + + self._folders_widget.set_name_filter(text) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + self._selected_folder_id = ( + self._folders_widget.get_selected_folder_id() + ) + self._selected_folder_label = ( + self._folders_widget.get_selected_folder_label() + ) + self.done(1) + + def set_selected_folder(self, folder_id): + """Change preselected folder before showing the dialog. + + This also resets model and clean filter. + """ + + if ( + self._folders_widget.is_refreshing + or self._folders_widget.get_project_name() is None + ): + self._folder_id_to_select = folder_id + else: + self._folders_widget.set_selected_folder(folder_id) + + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Selected folder id or None if nothing + is selected. + """ + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + +class FoldersField(BaseClickableFrame): + """Field where asset name of selected instance/s is showed. + + Click on the field will trigger `FoldersDialog`. + """ + value_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(FoldersField, self).__init__(parent) + self.setObjectName("AssetNameInputWidget") + + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = FoldersDialog(controller, parent) + + name_input = ClickableLineEdit(self) + name_input.setObjectName("AssetNameInput") + + icon = qtawesome.icon("fa.window-maximize", color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("AssetNameInputButton") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum) + self.setSizePolicy(size_policy) + + name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) + dialog.finished.connect(self._on_dialog_finish) + + self._controller = controller + self._dialog = dialog + self._name_input = name_input + self._icon_btn = icon_btn + + self._selected_folder_id = None + self._selected_folder_label = None + self._selected_items = [] + self._is_valid = True + + def refresh(self): + self._dialog.refresh() + + def is_valid(self): + """Is asset valid.""" + return self._is_valid + + def get_selected_folder_id(self): + """Selected asset names.""" + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + def set_text(self, text): + """Set text in text field. + + Does not change selected items (assets). + """ + self._name_input.setText(text) + + def set_valid(self, is_valid): + state = "" + if not is_valid: + state = "invalid" + self._set_state_property(state) + + def set_selected_item(self, folder_id=None, folder_label=None): + """Set folder for selection. + + Args: + folder_id (Optional[str]): Folder id to select. + folder_label (Optional[str]): Folder label. + """ + + self._selected_folder_id = folder_id + if not folder_id: + folder_label = None + elif folder_id and not folder_label: + folder_label = self._controller.get_folder_label(folder_id) + self._selected_folder_label = folder_label + self.set_text(folder_label if folder_label else "") + + def _on_dialog_finish(self, result): + if not result: + return + + folder_id = self._dialog.get_selected_folder_id() + folder_label = self._dialog.get_selected_folder_label() + self.set_selected_item(folder_id, folder_label) + + self.value_changed.emit() + + def _mouse_release_callback(self): + self._dialog.set_selected_folder(self._selected_folder_id) + self._dialog.open() + + def _set_state_property(self, state): + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py new file mode 100644 index 0000000000..50a49e0ce1 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py @@ -0,0 +1,94 @@ +from qtpy import QtWidgets, QtCore + +from openpype import style + + +class ButtonWithMenu(QtWidgets.QToolButton): + def __init__(self, parent=None): + super(ButtonWithMenu, self).__init__(parent) + + self.setObjectName("ButtonWithMenu") + + self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + menu = QtWidgets.QMenu(self) + + self.setMenu(menu) + + self._menu = menu + self._actions = [] + + def menu(self): + return self._menu + + def clear_actions(self): + if self._menu is not None: + self._menu.clear() + self._actions = [] + + def add_action(self, action): + self._actions.append(action) + self._menu.addAction(action) + + def _on_action_trigger(self): + action = self.sender() + if action not in self._actions: + return + action.trigger() + + +class SearchComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, parent): + super(SearchComboBox, self).__init__(parent) + + self.setEditable(True) + self.setInsertPolicy(QtWidgets.QComboBox.NoInsert) + + combobox_delegate = QtWidgets.QStyledItemDelegate(self) + self.setItemDelegate(combobox_delegate) + + completer = self.completer() + completer.setCompletionMode( + QtWidgets.QCompleter.PopupCompletion + ) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + completer_view = completer.popup() + completer_view.setObjectName("CompleterView") + completer_delegate = QtWidgets.QStyledItemDelegate(completer_view) + completer_view.setItemDelegate(completer_delegate) + completer_view.setStyleSheet(style.load_stylesheet()) + + self._combobox_delegate = combobox_delegate + + self._completer_delegate = completer_delegate + self._completer = completer + + def set_placeholder(self, placeholder): + self.lineEdit().setPlaceholderText(placeholder) + + def populate(self, items): + self.clear() + self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text or None + + def set_valid_value(self, value): + """Try to locate 'value' and pre-select it in dropdown.""" + index = self.findText(value) + if index > -1: + self.setCurrentIndex(index) diff --git a/openpype/tools/ayon_sceneinventory/view.py b/openpype/tools/ayon_sceneinventory/view.py new file mode 100644 index 0000000000..039b498b1b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/view.py @@ -0,0 +1,825 @@ +import uuid +import collections +import logging +import itertools +from functools import partial + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_version_by_id, + get_versions, + get_hero_versions, + get_representation_by_id, + get_representations, +) +from openpype import style +from openpype.pipeline import ( + HeroVersionType, + update_container, + remove_container, + discover_inventory_actions, +) +from openpype.tools.utils.lib import ( + iter_model_rows, + format_version +) + +from .switch_dialog import SwitchAssetDialog +from .model import InventoryModel + + +DEFAULT_COLOR = "#fb9c15" + +log = logging.getLogger("SceneInventory") + + +class SceneInventoryView(QtWidgets.QTreeView): + data_changed = QtCore.Signal() + hierarchy_view_changed = QtCore.Signal(bool) + + def __init__(self, controller, parent): + super(SceneInventoryView, self).__init__(parent=parent) + + # view settings + self.setIndentation(12) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + self.customContextMenuRequested.connect(self._show_right_mouse_menu) + + self._hierarchy_view = False + self._selected = None + + self._controller = controller + + def _set_hierarchy_view(self, enabled): + if enabled == self._hierarchy_view: + return + self._hierarchy_view = enabled + self.hierarchy_view_changed.emit(enabled) + + def _enter_hierarchy(self, items): + self._selected = set(i["objectName"] for i in items) + self._set_hierarchy_view(True) + self.data_changed.emit() + self.expandToDepth(1) + self.setStyleSheet(""" + QTreeView { + border-color: #fb9c15; + } + """) + + def _leave_hierarchy(self): + self._set_hierarchy_view(False) + self.data_changed.emit() + self.setStyleSheet("QTreeView {}") + + def _build_item_menu_for_selection(self, items, menu): + # Exclude items that are "NOT FOUND" since setting versions, updating + # and removal won't work for those items. + items = [item for item in items if not item.get("isNotFound")] + if not items: + return + + # An item might not have a representation, for example when an item + # is listed as "NOT FOUND" + repre_ids = set() + for item in items: + repre_id = item["representation"] + try: + uuid.UUID(repre_id) + repre_ids.add(repre_id) + except ValueError: + pass + + project_name = self._controller.get_current_project_name() + repre_docs = get_representations( + project_name, representation_ids=repre_ids, fields=["parent"] + ) + + version_ids = { + repre_doc["parent"] + for repre_doc in repre_docs + } + + loaded_versions = get_versions( + project_name, version_ids=version_ids, hero=True + ) + + loaded_hero_versions = [] + versions_by_parent_id = collections.defaultdict(list) + subset_ids = set() + for version in loaded_versions: + if version["type"] == "hero_version": + loaded_hero_versions.append(version) + else: + parent_id = version["parent"] + versions_by_parent_id[parent_id].append(version) + subset_ids.add(parent_id) + + all_versions = get_versions( + project_name, subset_ids=subset_ids, hero=True + ) + hero_versions = [] + versions = [] + for version in all_versions: + if version["type"] == "hero_version": + hero_versions.append(version) + else: + versions.append(version) + + has_loaded_hero_versions = len(loaded_hero_versions) > 0 + has_available_hero_version = len(hero_versions) > 0 + has_outdated = False + + for version in versions: + parent_id = version["parent"] + current_versions = versions_by_parent_id[parent_id] + for current_version in current_versions: + if current_version["name"] < version["name"]: + has_outdated = True + break + + if has_outdated: + break + + switch_to_versioned = None + if has_loaded_hero_versions: + def _on_switch_to_versioned(items): + repre_ids = { + item["representation"] + for item in items + } + + repre_docs = get_representations( + project_name, + representation_ids=repre_ids, + fields=["parent"] + ) + + version_ids = set() + version_id_by_repre_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + repre_id = str(repre_doc["_id"]) + version_id_by_repre_id[repre_id] = version_id + version_ids.add(version_id) + + hero_versions = get_hero_versions( + project_name, + version_ids=version_ids, + fields=["version_id"] + ) + + hero_src_version_ids = set() + for hero_version in hero_versions: + version_id = hero_version["version_id"] + hero_src_version_ids.add(version_id) + hero_version_id = hero_version["_id"] + for _repre_id, current_version_id in ( + version_id_by_repre_id.items() + ): + if current_version_id == hero_version_id: + version_id_by_repre_id[_repre_id] = version_id + + version_docs = get_versions( + project_name, + version_ids=hero_src_version_ids, + fields=["name"] + ) + version_name_by_id = {} + for version_doc in version_docs: + version_name_by_id[version_doc["_id"]] = \ + version_doc["name"] + + # Specify version per item to update to + update_items = [] + update_versions = [] + for item in items: + repre_id = item["representation"] + version_id = version_id_by_repre_id.get(repre_id) + version_name = version_name_by_id.get(version_id) + if version_name is not None: + update_items.append(item) + update_versions.append(version_name) + self._update_containers(update_items, update_versions) + + update_icon = qtawesome.icon( + "fa.asterisk", + color=DEFAULT_COLOR + ) + switch_to_versioned = QtWidgets.QAction( + update_icon, + "Switch to versioned", + menu + ) + switch_to_versioned.triggered.connect( + lambda: _on_switch_to_versioned(items) + ) + + update_to_latest_action = None + if has_outdated or has_loaded_hero_versions: + update_icon = qtawesome.icon( + "fa.angle-double-up", + color=DEFAULT_COLOR + ) + update_to_latest_action = QtWidgets.QAction( + update_icon, + "Update to latest", + menu + ) + update_to_latest_action.triggered.connect( + lambda: self._update_containers(items, version=-1) + ) + + change_to_hero = None + if has_available_hero_version: + # TODO change icon + change_icon = qtawesome.icon( + "fa.asterisk", + color="#00b359" + ) + change_to_hero = QtWidgets.QAction( + change_icon, + "Change to hero", + menu + ) + change_to_hero.triggered.connect( + lambda: self._update_containers(items, + version=HeroVersionType(-1)) + ) + + # set version + set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_action = QtWidgets.QAction( + set_version_icon, + "Set version", + menu + ) + set_version_action.triggered.connect( + lambda: self._show_version_dialog(items)) + + # switch folder + switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR) + switch_folder_action = QtWidgets.QAction( + switch_folder_icon, + "Switch Folder", + menu + ) + switch_folder_action.triggered.connect( + lambda: self._show_switch_dialog(items)) + + # remove + remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) + remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu) + remove_action.triggered.connect( + lambda: self._show_remove_warning_dialog(items)) + + # add the actions + if switch_to_versioned: + menu.addAction(switch_to_versioned) + + if update_to_latest_action: + menu.addAction(update_to_latest_action) + + if change_to_hero: + menu.addAction(change_to_hero) + + menu.addAction(set_version_action) + menu.addAction(switch_folder_action) + + menu.addSeparator() + + menu.addAction(remove_action) + + self._handle_sync_server(menu, repre_ids) + + def _handle_sync_server(self, menu, repre_ids): + """Adds actions for download/upload when SyncServer is enabled + + Args: + menu (OptionMenu) + repre_ids (list) of object_ids + + Returns: + (OptionMenu) + """ + + if not self._controller.is_sync_server_enabled(): + return + + menu.addSeparator() + + download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR) + download_active_action = QtWidgets.QAction( + download_icon, + "Download", + menu + ) + download_active_action.triggered.connect( + lambda: self._add_sites(repre_ids, "active_site")) + + upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR) + upload_remote_action = QtWidgets.QAction( + upload_icon, + "Upload", + menu + ) + upload_remote_action.triggered.connect( + lambda: self._add_sites(repre_ids, "remote_site")) + + menu.addAction(download_active_action) + menu.addAction(upload_remote_action) + + def _add_sites(self, repre_ids, site_type): + """(Re)sync all 'repre_ids' to specific site. + + It checks if opposite site has fully available content to limit + accidents. (ReSync active when no remote >> losing active content) + + Args: + repre_ids (list) + site_type (Literal[active_site, remote_site]): Site type. + """ + + self._controller.resync_representations(repre_ids, site_type) + + self.data_changed.emit() + + def _build_item_menu(self, items=None): + """Create menu for the selected items""" + + if not items: + items = [] + + menu = QtWidgets.QMenu(self) + + # add the actions + self._build_item_menu_for_selection(items, menu) + + # These two actions should be able to work without selection + # expand all items + expandall_action = QtWidgets.QAction(menu, text="Expand all items") + expandall_action.triggered.connect(self.expandAll) + + # collapse all items + collapse_action = QtWidgets.QAction(menu, text="Collapse all items") + collapse_action.triggered.connect(self.collapseAll) + + menu.addAction(expandall_action) + menu.addAction(collapse_action) + + custom_actions = self._get_custom_actions(containers=items) + if custom_actions: + submenu = QtWidgets.QMenu("Actions", self) + for action in custom_actions: + color = action.color or DEFAULT_COLOR + icon = qtawesome.icon("fa.%s" % action.icon, color=color) + action_item = QtWidgets.QAction(icon, action.label, submenu) + action_item.triggered.connect( + partial(self._process_custom_action, action, items)) + + submenu.addAction(action_item) + + menu.addMenu(submenu) + + # go back to flat view + back_to_flat_action = None + if self._hierarchy_view: + back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR) + back_to_flat_action = QtWidgets.QAction( + back_to_flat_icon, + "Back to Full-View", + menu + ) + back_to_flat_action.triggered.connect(self._leave_hierarchy) + + # send items to hierarchy view + enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8") + enter_hierarchy_action = QtWidgets.QAction( + enter_hierarchy_icon, + "Cherry-Pick (Hierarchy)", + menu + ) + enter_hierarchy_action.triggered.connect( + lambda: self._enter_hierarchy(items)) + + if items: + menu.addAction(enter_hierarchy_action) + + if back_to_flat_action is not None: + menu.addAction(back_to_flat_action) + + return menu + + def _get_custom_actions(self, containers): + """Get the registered Inventory Actions + + Args: + containers(list): collection of containers + + Returns: + list: collection of filter and initialized actions + """ + + def sorter(Plugin): + """Sort based on order attribute of the plugin""" + return Plugin.order + + # Fedd an empty dict if no selection, this will ensure the compat + # lookup always work, so plugin can interact with Scene Inventory + # reversely. + containers = containers or [dict()] + + # Check which action will be available in the menu + Plugins = discover_inventory_actions() + compatible = [p() for p in Plugins if + any(p.is_compatible(c) for c in containers)] + + return sorted(compatible, key=sorter) + + def _process_custom_action(self, action, containers): + """Run action and if results are returned positive update the view + + If the result is list or dict, will select view items by the result. + + Args: + action (InventoryAction): Inventory Action instance + containers (list): Data of currently selected items + + Returns: + None + """ + + result = action.process(containers) + if result: + self.data_changed.emit() + + if isinstance(result, (list, set)): + self._select_items_by_action(result) + + if isinstance(result, dict): + self._select_items_by_action( + result["objectNames"], result["options"] + ) + + def _select_items_by_action(self, object_names, options=None): + """Select view items by the result of action + + Args: + object_names (list or set): A list/set of container object name + options (dict): GUI operation options. + + Returns: + None + + """ + options = options or dict() + + if options.get("clear", True): + self.clearSelection() + + object_names = set(object_names) + if ( + self._hierarchy_view + and not self._selected.issuperset(object_names) + ): + # If any container not in current cherry-picked view, update + # view before selecting them. + self._selected.update(object_names) + self.data_changed.emit() + + model = self.model() + selection_model = self.selectionModel() + + select_mode = { + "select": QtCore.QItemSelectionModel.Select, + "deselect": QtCore.QItemSelectionModel.Deselect, + "toggle": QtCore.QItemSelectionModel.Toggle, + }[options.get("mode", "select")] + + for index in iter_model_rows(model, 0): + item = index.data(InventoryModel.ItemRole) + if item.get("isGroupNode"): + continue + + name = item.get("objectName") + if name in object_names: + self.scrollTo(index) # Ensure item is visible + flags = select_mode | QtCore.QItemSelectionModel.Rows + selection_model.select(index, flags) + + object_names.remove(name) + + if len(object_names) == 0: + break + + def _show_right_mouse_menu(self, pos): + """Display the menu when at the position of the item clicked""" + + globalpos = self.viewport().mapToGlobal(pos) + + if not self.selectionModel().hasSelection(): + print("No selection") + # Build menu without selection, feed an empty list + menu = self._build_item_menu() + menu.exec_(globalpos) + return + + active = self.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + + # move index under mouse + indices = self.get_indices() + if active in indices: + indices.remove(active) + + indices.append(active) + + # Extend to the sub-items + all_indices = self._extend_to_children(indices) + items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices + if i.parent().isValid()] + + if self._hierarchy_view: + # Ensure no group item + items = [n for n in items if not n.get("isGroupNode")] + + menu = self._build_item_menu(items) + menu.exec_(globalpos) + + def get_indices(self): + """Get the selected rows""" + selection_model = self.selectionModel() + return selection_model.selectedRows() + + def _extend_to_children(self, indices): + """Extend the indices to the children indices. + + Top-level indices are extended to its children indices. Sub-items + are kept as is. + + Args: + indices (list): The indices to extend. + + Returns: + list: The children indices + + """ + def get_children(i): + model = i.model() + rows = model.rowCount(parent=i) + for row in range(rows): + child = model.index(row, 0, parent=i) + yield child + + subitems = set() + for i in indices: + valid_parent = i.parent().isValid() + if valid_parent and i not in subitems: + subitems.add(i) + + if self._hierarchy_view: + # Assume this is a group item + for child in get_children(i): + subitems.add(child) + else: + # is top level item + for child in get_children(i): + subitems.add(child) + + return list(subitems) + + def _show_version_dialog(self, items): + """Create a dialog with the available versions for the selected file + + Args: + items (list): list of items to run the "set_version" for + + Returns: + None + """ + + active = items[-1] + + project_name = self._controller.get_current_project_name() + # Get available versions for active representation + repre_doc = get_representation_by_id( + project_name, + active["representation"], + fields=["parent"] + ) + + repre_version_doc = get_version_by_id( + project_name, + repre_doc["parent"], + fields=["parent"] + ) + + version_docs = list(get_versions( + project_name, + subset_ids=[repre_version_doc["parent"]], + hero=True + )) + hero_version = None + standard_versions = [] + for version_doc in version_docs: + if version_doc["type"] == "hero_version": + hero_version = version_doc + else: + standard_versions.append(version_doc) + versions = list(reversed( + sorted(standard_versions, key=lambda item: item["name"]) + )) + if hero_version: + _version_id = hero_version["version_id"] + for _version in versions: + if _version["_id"] != _version_id: + continue + + hero_version["name"] = HeroVersionType( + _version["name"] + ) + hero_version["data"] = _version["data"] + break + + # Get index among the listed versions + current_item = None + current_version = active["version"] + if isinstance(current_version, HeroVersionType): + current_item = hero_version + else: + for version in versions: + if version["name"] == current_version: + current_item = version + break + + all_versions = [] + if hero_version: + all_versions.append(hero_version) + all_versions.extend(versions) + + if current_item: + index = all_versions.index(current_item) + else: + index = 0 + + versions_by_label = dict() + labels = [] + for version in all_versions: + is_hero = version["type"] == "hero_version" + label = format_version(version["name"], is_hero) + labels.append(label) + versions_by_label[label] = version["name"] + + label, state = QtWidgets.QInputDialog.getItem( + self, + "Set version..", + "Set version number to", + labels, + current=index, + editable=False + ) + if not state: + return + + if label: + version = versions_by_label[label] + self._update_containers(items, version) + + def _show_switch_dialog(self, items): + """Display Switch dialog""" + dialog = SwitchAssetDialog(self._controller, self, items) + dialog.switched.connect(self.data_changed.emit) + dialog.show() + + def _show_remove_warning_dialog(self, items): + """Prompt a dialog to inform the user the action will remove items""" + + accept = QtWidgets.QMessageBox.Ok + buttons = accept | QtWidgets.QMessageBox.Cancel + + state = QtWidgets.QMessageBox.question( + self, + "Are you sure?", + "Are you sure you want to remove {} item(s)".format(len(items)), + buttons=buttons, + defaultButton=accept + ) + + if state != accept: + return + + for item in items: + remove_container(item) + self.data_changed.emit() + + def _show_version_error_dialog(self, version, items): + """Shows QMessageBox when version switch doesn't work + + Args: + version: str or int or None + """ + if version == -1: + version_str = "latest" + elif isinstance(version, HeroVersionType): + version_str = "hero" + elif isinstance(version, int): + version_str = "v{:03d}".format(version) + else: + version_str = version + + dialog = QtWidgets.QMessageBox(self) + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle("Update failed") + + switch_btn = dialog.addButton( + "Switch Folder", + QtWidgets.QMessageBox.ActionRole + ) + switch_btn.clicked.connect(lambda: self._show_switch_dialog(items)) + + dialog.addButton(QtWidgets.QMessageBox.Cancel) + + msg = ( + "Version update to '{}' failed as representation doesn't exist." + "\n\nPlease update to version with a valid representation" + " OR \n use 'Switch Folder' button to change folder." + ).format(version_str) + dialog.setText(msg) + dialog.exec_() + + def update_all(self): + """Update all items that are currently 'outdated' in the view""" + # Get the source model through the proxy model + model = self.model().sourceModel() + + # Get all items from outdated groups + outdated_items = [] + for index in iter_model_rows(model, + column=0, + include_root=False): + item = index.data(model.ItemRole) + + if not item.get("isGroupNode"): + continue + + # Only the group nodes contain the "highest_version" data and as + # such we find only the groups and take its children. + if not model.outdated(item): + continue + + # Collect all children which we want to update + children = item.children() + outdated_items.extend(children) + + if not outdated_items: + log.info("Nothing to update.") + return + + # Trigger update to latest + self._update_containers(outdated_items, version=-1) + + def _update_containers(self, items, version): + """Helper to update items to given version (or version per item) + + If at least one item is specified this will always try to refresh + the inventory even if errors occurred on any of the items. + + Arguments: + items (list): Items to update + version (int or list): Version to set to. + This can be a list specifying a version for each item. + Like `update_container` version -1 sets the latest version + and HeroTypeVersion instances set the hero version. + + """ + + if isinstance(version, (list, tuple)): + # We allow a unique version to be specified per item. In that case + # the length must match with the items + assert len(items) == len(version), ( + "Number of items mismatches number of versions: " + "{} items - {} versions".format(len(items), len(version)) + ) + versions = version + else: + # Repeat the same version infinitely + versions = itertools.repeat(version) + + # Trigger update to latest + try: + for item, item_version in zip(items, versions): + try: + update_container(item, item_version) + except AssertionError: + self._show_version_error_dialog(item_version, [item]) + log.warning("Update failed", exc_info=True) + finally: + # Always update the scene inventory view, even if errors occurred + self.data_changed.emit() diff --git a/openpype/tools/ayon_sceneinventory/window.py b/openpype/tools/ayon_sceneinventory/window.py new file mode 100644 index 0000000000..427bf4c50d --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/window.py @@ -0,0 +1,200 @@ +from qtpy import QtWidgets, QtCore, QtGui +import qtawesome + +from openpype import style, resources +from openpype.tools.utils.delegates import VersionDelegate +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) +from openpype.tools.ayon_sceneinventory import SceneInventoryController + +from .model import ( + InventoryModel, + FilterProxyModel +) +from .view import SceneInventoryView + + +class ControllerVersionDelegate(VersionDelegate): + """Version delegate that uses controller to get project. + + Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't + worry about the variable name, object is stored to '_dbcon' attribute. + """ + + def get_project_name(self): + self._dbcon.get_current_project_name() + + +class SceneInventoryWindow(QtWidgets.QDialog): + """Scene Inventory window""" + + def __init__(self, controller=None, parent=None): + super(SceneInventoryWindow, self).__init__(parent) + + if controller is None: + controller = SceneInventoryController() + + project_name = controller.get_current_project_name() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Scene Inventory - {}".format(project_name)) + self.setObjectName("SceneInventory") + + self.resize(1100, 480) + + # region control + + filter_label = QtWidgets.QLabel("Search", self) + text_filter = QtWidgets.QLineEdit(self) + + outdated_only_checkbox = QtWidgets.QCheckBox( + "Filter to outdated", self + ) + outdated_only_checkbox.setToolTip("Show outdated files only") + outdated_only_checkbox.setChecked(False) + + icon = qtawesome.icon("fa.arrow-up", color="white") + update_all_button = QtWidgets.QPushButton(self) + update_all_button.setToolTip("Update all outdated to latest version") + update_all_button.setIcon(icon) + + icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton(self) + refresh_button.setToolTip("Refresh") + refresh_button.setIcon(icon) + + control_layout = QtWidgets.QHBoxLayout() + control_layout.addWidget(filter_label) + control_layout.addWidget(text_filter) + control_layout.addWidget(outdated_only_checkbox) + control_layout.addWidget(update_all_button) + control_layout.addWidget(refresh_button) + + model = InventoryModel(controller) + proxy = FilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = SceneInventoryView(controller, self) + view.setModel(proxy) + + sync_enabled = controller.is_sync_server_enabled() + view.setColumnHidden(model.active_site_col, not sync_enabled) + view.setColumnHidden(model.remote_site_col, not sync_enabled) + + # set some nice default widths for the view + view.setColumnWidth(0, 250) # name + view.setColumnWidth(1, 55) # version + view.setColumnWidth(2, 55) # count + view.setColumnWidth(3, 150) # family + view.setColumnWidth(4, 120) # group + view.setColumnWidth(5, 150) # loader + + # apply delegates + version_delegate = ControllerVersionDelegate(controller, self) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(control_layout) + layout.addWidget(view) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + # signals + show_timer.timeout.connect(self._on_show_timer) + text_filter.textChanged.connect(self._on_text_filter_change) + outdated_only_checkbox.stateChanged.connect( + self._on_outdated_state_change + ) + view.hierarchy_view_changed.connect( + self._on_hierarchy_view_change + ) + view.data_changed.connect(self._on_refresh_request) + refresh_button.clicked.connect(self._on_refresh_request) + update_all_button.clicked.connect(self._on_update_all) + + self._show_timer = show_timer + self._show_counter = 0 + self._controller = controller + self._update_all_button = update_all_button + self._outdated_only_checkbox = outdated_only_checkbox + self._view = view + self._model = model + self._proxy = proxy + self._version_delegate = version_delegate + + self._first_show = True + self._first_refresh = True + + def showEvent(self, event): + super(SceneInventoryWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + self._show_counter = 0 + self._show_timer.start() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + + """ + + def _on_refresh_request(self): + """Signal callback to trigger 'refresh' without any arguments.""" + + self.refresh() + + def refresh(self, containers=None): + self._first_refresh = False + self._controller.reset() + with preserve_expanded_rows( + tree_view=self._view, + role=self._model.UniqueRole + ): + with preserve_selection( + tree_view=self._view, + role=self._model.UniqueRole, + current_index=False + ): + kwargs = {"containers": containers} + # TODO do not touch view's inner attribute + if self._view._hierarchy_view: + kwargs["selected"] = self._view._selected + self._model.refresh(**kwargs) + + def _on_show_timer(self): + if self._show_counter < 3: + self._show_counter += 1 + return + self._show_timer.stop() + self.refresh() + + def _on_hierarchy_view_change(self, enabled): + self._proxy.set_hierarchy_view(enabled) + self._model.set_hierarchy_view(enabled) + + def _on_text_filter_change(self, text_filter): + if hasattr(self._proxy, "setFilterRegExp"): + self._proxy.setFilterRegExp(text_filter) + else: + self._proxy.setFilterRegularExpression(text_filter) + + def _on_outdated_state_change(self): + self._proxy.set_filter_outdated( + self._outdated_only_checkbox.isChecked() + ) + + def _on_update_all(self): + self._view.update_all() diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index c71c87f9b0..c51323e556 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -24,9 +24,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): lock = False def __init__(self, dbcon, *args, **kwargs): - self.dbcon = dbcon + self._dbcon = dbcon super(VersionDelegate, self).__init__(*args, **kwargs) + def get_project_name(self): + return self._dbcon.active_project() + def displayText(self, value, locale): if isinstance(value, HeroVersionType): return lib.format_version(value, True) @@ -120,7 +123,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): "Version is not integer" ) - project_name = self.dbcon.active_project() + project_name = self.get_project_name() # Add all available versions to the editor parent_id = item["version_document"]["parent"] version_docs = [ diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index ca23945339..29c8c0ba8e 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -171,14 +171,23 @@ class HostToolsHelper: def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: - from openpype.tools.sceneinventory import SceneInventoryWindow - host = registered_host() ILoadHost.validate_load_methods(host) - scene_inventory_window = SceneInventoryWindow( - parent=parent or self._parent - ) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_sceneinventory.window import ( + SceneInventoryWindow) + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) + + else: + from openpype.tools.sceneinventory import SceneInventoryWindow + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) self._scene_inventory_tool = scene_inventory_window return self._scene_inventory_tool From 08baaca5a339adc11ebfb4fc77ad1d163df759f6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 13 Oct 2023 14:18:03 +0200 Subject: [PATCH 305/327] Refactor `SelectInvalidAction` to behave like other action for other host, create `SelectInstanceNodeAction` as dedicated action to select the instance node for a failed plugin. - Note: Selecting Instance Node will still select the instance node even if the user has currently 'fixed' the problem. --- openpype/hosts/nuke/api/__init__.py | 6 +- openpype/hosts/nuke/api/actions.py | 59 ++++++++++++++----- openpype/hosts/nuke/api/lib.py | 5 +- .../plugins/publish/validate_asset_context.py | 4 +- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index a01f5bda0a..c6ccd0baf1 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -50,7 +50,10 @@ from .utils import ( get_colorspace_list ) -from .actions import SelectInvalidAction +from .actions import ( + SelectInvalidAction, + SelectInstanceNodeAction +) __all__ = ( "file_extensions", @@ -97,4 +100,5 @@ __all__ = ( "get_colorspace_list", "SelectInvalidAction", + "SelectInstanceNodeAction" ) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index ca3c8393ed..995e6427af 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -18,6 +18,38 @@ class SelectInvalidAction(pyblish.api.Action): on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon + def process(self, context, plugin): + + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) + + # Get the invalid nodes for the plug-ins + self.log.info("Finding invalid nodes..") + invalid = set() + for instance in errored_instances: + invalid_nodes = plugin.get_invalid(instance) + + if invalid_nodes: + if isinstance(invalid_nodes, (list, tuple)): + invalid.update(invalid_nodes) + else: + self.log.warning("Plug-in returned to be invalid, " + "but has no selectable nodes.") + + if invalid: + self.log.info("Selecting invalid nodes: {}".format(invalid)) + reset_selection() + select_nodes(invalid) + else: + self.log.info("No invalid nodes found.") + + +class SelectInstanceNodeAction(pyblish.api.Action): + """Select instance node for failed plugin.""" + label = "Select instance node" + on = "failed" # This action is only available on a failed plug-in + icon = "mdi.cursor-default-click" + def process(self, context, plugin): # Get the errored instances for the plug-in @@ -25,26 +57,21 @@ class SelectInvalidAction(pyblish.api.Action): context, plugin) # Get the invalid nodes for the plug-ins - self.log.info("Finding invalid nodes..") - invalid_nodes = set() + self.log.info("Finding instance nodes..") + nodes = set() for instance in errored_instances: - invalid = plugin.get_invalid(instance) - - if not invalid: - continue - - select_node = instance.data.get("transientData", {}).get("node") - if not select_node: + instance_node = instance.data.get("transientData", {}).get("node") + if not instance_node: raise RuntimeError( "No transientData['node'] found on instance: {}".format( - instance) + instance + ) ) + nodes.add(instance_node) - invalid_nodes.add(select_node) - - if invalid_nodes: - self.log.info("Selecting invalid nodes: {}".format(invalid_nodes)) + if nodes: + self.log.info("Selecting instance nodes: {}".format(nodes)) reset_selection() - select_nodes(list(invalid_nodes)) + select_nodes(nodes) else: - self.log.info("No invalid nodes found.") + self.log.info("No instance nodes found.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 390545b806..62f3a3c3ff 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2833,9 +2833,10 @@ def select_nodes(nodes): """Selects all inputted nodes Arguments: - nodes (list): nuke nodes to be selected + nodes (Union[list, tuple, set]): nuke nodes to be selected """ - assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple" + assert isinstance(nodes, (list, tuple, set)), \ + "nodes has to be list, tuple or set" for node in nodes: node["selected"].setValue(True) diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py index ab62daeaeb..731645a11c 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -10,7 +10,7 @@ from openpype.pipeline.publish import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) -from openpype.hosts.nuke.api import SelectInvalidAction +from openpype.hosts.nuke.api import SelectInstanceNodeAction class ValidateCorrectAssetContext( @@ -30,7 +30,7 @@ class ValidateCorrectAssetContext( hosts = ["nuke"] actions = [ RepairAction, - SelectInvalidAction + SelectInstanceNodeAction ] optional = True From 3bbf3c0db93a5cc5db99a929a0f7cefa2a17cf02 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 13 Oct 2023 14:32:21 +0200 Subject: [PATCH 306/327] Tweak logging for Nuke for artist facing reports --- .../nuke/plugins/publish/collect_backdrop.py | 2 +- .../nuke/plugins/publish/collect_context_data.py | 2 +- .../hosts/nuke/plugins/publish/collect_gizmo.py | 2 +- .../hosts/nuke/plugins/publish/collect_model.py | 2 +- .../nuke/plugins/publish/collect_slate_node.py | 2 +- .../nuke/plugins/publish/collect_workfile.py | 4 +++- .../nuke/plugins/publish/extract_backdrop.py | 4 +--- .../hosts/nuke/plugins/publish/extract_camera.py | 8 +++----- .../hosts/nuke/plugins/publish/extract_gizmo.py | 5 +---- .../hosts/nuke/plugins/publish/extract_model.py | 8 ++++---- .../nuke/plugins/publish/extract_ouput_node.py | 2 +- .../nuke/plugins/publish/extract_render_local.py | 4 ++-- .../plugins/publish/extract_review_data_lut.py | 4 ++-- .../publish/extract_review_intermediates.py | 15 ++++++++------- .../nuke/plugins/publish/extract_script_save.py | 5 ++--- .../nuke/plugins/publish/extract_slate_frame.py | 14 +++++++------- .../nuke/plugins/publish/extract_thumbnail.py | 4 ++-- .../nuke/plugins/publish/validate_backdrop.py | 4 ++-- .../plugins/publish/validate_output_resolution.py | 4 ++-- .../plugins/publish/validate_rendered_frames.py | 14 +++++++------- .../nuke/plugins/publish/validate_write_nodes.py | 2 +- 21 files changed, 53 insertions(+), 58 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py index 7d51af7e9e..d04c1204e3 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py @@ -57,4 +57,4 @@ class CollectBackdrops(pyblish.api.InstancePlugin): if version: instance.data['version'] = version - self.log.info("Backdrop instance collected: `{}`".format(instance)) + self.log.debug("Backdrop instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_context_data.py b/openpype/hosts/nuke/plugins/publish/collect_context_data.py index f1b4965205..b85e924f55 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_context_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_context_data.py @@ -64,4 +64,4 @@ class CollectContextData(pyblish.api.ContextPlugin): context.data["scriptData"] = script_data context.data.update(script_data) - self.log.info('Context from Nuke script collected') + self.log.debug('Context from Nuke script collected') diff --git a/openpype/hosts/nuke/plugins/publish/collect_gizmo.py b/openpype/hosts/nuke/plugins/publish/collect_gizmo.py index e3c40a7a90..c410de7c32 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/collect_gizmo.py @@ -43,4 +43,4 @@ class CollectGizmo(pyblish.api.InstancePlugin): "frameStart": first_frame, "frameEnd": last_frame }) - self.log.info("Gizmo instance collected: `{}`".format(instance)) + self.log.debug("Gizmo instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_model.py b/openpype/hosts/nuke/plugins/publish/collect_model.py index 3fdf376d0c..a099f06be0 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_model.py +++ b/openpype/hosts/nuke/plugins/publish/collect_model.py @@ -43,4 +43,4 @@ class CollectModel(pyblish.api.InstancePlugin): "frameStart": first_frame, "frameEnd": last_frame }) - self.log.info("Model instance collected: `{}`".format(instance)) + self.log.debug("Model instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py index c7d65ffd24..3baa0cd9b5 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py +++ b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py @@ -39,7 +39,7 @@ class CollectSlate(pyblish.api.InstancePlugin): instance.data["slateNode"] = slate_node instance.data["slate"] = True instance.data["families"].append("slate") - self.log.info( + self.log.debug( "Slate node is in node graph: `{}`".format(slate.name())) self.log.debug( "__ instance.data: `{}`".format(instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_workfile.py b/openpype/hosts/nuke/plugins/publish/collect_workfile.py index 852042e6e9..0f03572f8b 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/collect_workfile.py @@ -37,4 +37,6 @@ class CollectWorkfile(pyblish.api.InstancePlugin): # adding basic script data instance.data.update(script_data) - self.log.info("Collect script version") + self.log.debug( + "Collected current script version: {}".format(current_file) + ) diff --git a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py index 5166fa4b2c..2a6a5dee2a 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py @@ -56,8 +56,6 @@ class ExtractBackdropNode(publish.Extractor): # connect output node for n, output in connections_out.items(): opn = nuke.createNode("Output") - self.log.info(n.name()) - self.log.info(output.name()) output.setInput( next((i for i, d in enumerate(output.dependencies()) if d.name() in n.name()), 0), opn) @@ -102,5 +100,5 @@ class ExtractBackdropNode(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{}' to: {}".format( + self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index 33df6258ae..b0facd379a 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -36,11 +36,11 @@ class ExtractCamera(publish.Extractor): step = 1 output_range = str(nuke.FrameRange(first_frame, last_frame, step)) - self.log.info("instance.data: `{}`".format( + self.log.debug("instance.data: `{}`".format( pformat(instance.data))) rm_nodes = [] - self.log.info("Crating additional nodes") + self.log.debug("Creating additional nodes for 3D Camera Extractor") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) @@ -84,8 +84,6 @@ class ExtractCamera(publish.Extractor): for n in rm_nodes: nuke.delete(n) - self.log.info(file_path) - # create representation data if "representations" not in instance.data: instance.data["representations"] = [] @@ -112,7 +110,7 @@ class ExtractCamera(publish.Extractor): "frameEndHandle": last_frame, }) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py index b0b1a9f7b7..ecec0d6f80 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py @@ -85,8 +85,5 @@ class ExtractGizmo(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{}' to: {}".format( + self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) - - self.log.info("Data {}".format( - instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 00462f8035..a8b37fb173 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -33,13 +33,13 @@ class ExtractModel(publish.Extractor): first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) - self.log.info("instance.data: `{}`".format( + self.log.debug("instance.data: `{}`".format( pformat(instance.data))) rm_nodes = [] model_node = instance.data["transientData"]["node"] - self.log.info("Crating additional nodes") + self.log.debug("Creating additional nodes for Extract Model") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) @@ -76,7 +76,7 @@ class ExtractModel(publish.Extractor): for n in rm_nodes: nuke.delete(n) - self.log.info(file_path) + self.log.debug("Filepath: {}".format(file_path)) # create representation data if "representations" not in instance.data: @@ -104,5 +104,5 @@ class ExtractModel(publish.Extractor): "frameEndHandle": last_frame, }) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index e66cfd9018..3fe1443bb3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -27,7 +27,7 @@ class CreateOutputNode(pyblish.api.ContextPlugin): if active_node: active_node = active_node.pop() - self.log.info(active_node) + self.log.debug("Active node: {}".format(active_node)) active_node['selected'].setValue(True) # select only instance render node diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index e2cf2addc5..ff04367e20 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -119,7 +119,7 @@ class NukeRenderLocal(publish.Extractor, instance.data["representations"].append(repre) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, out_dir )) @@ -143,7 +143,7 @@ class NukeRenderLocal(publish.Extractor, instance.data["families"] = families collections, remainder = clique.assemble(filenames) - self.log.info('collections: {}'.format(str(collections))) + self.log.debug('collections: {}'.format(str(collections))) if collections: collection = collections[0] diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 2a26ed82fb..b007f90f6c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -20,7 +20,7 @@ class ExtractReviewDataLut(publish.Extractor): hosts = ["nuke"] def process(self, instance): - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" in instance.data: staging_dir = instance.data[ "representations"][0]["stagingDir"].replace("\\", "/") @@ -33,7 +33,7 @@ class ExtractReviewDataLut(publish.Extractor): staging_dir = os.path.normpath(os.path.dirname(render_path)) instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) # generate data diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 9730e3b61f..3ee166eb56 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -52,7 +52,7 @@ class ExtractReviewIntermediates(publish.Extractor): task_type = instance.context.data["taskType"] subset = instance.data["subset"] - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" not in instance.data: instance.data["representations"] = [] @@ -62,10 +62,10 @@ class ExtractReviewIntermediates(publish.Extractor): instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) - self.log.info(self.outputs) + self.log.debug("Outputs: {}".format(self.outputs)) # generate data with maintained_selection(): @@ -104,9 +104,10 @@ class ExtractReviewIntermediates(publish.Extractor): re.search(s, subset) for s in f_subsets): continue - self.log.info( + self.log.debug( "Baking output `{}` with settings: {}".format( - o_name, o_data)) + o_name, o_data) + ) # check if settings have more then one preset # so we dont need to add outputName to representation @@ -155,10 +156,10 @@ class ExtractReviewIntermediates(publish.Extractor): instance.data["useSequenceForReview"] = False else: instance.data["families"].remove("review") - self.log.info(( + self.log.debug( "Removing `review` from families. " "Not available baking profile." - )) + ) self.log.debug(instance.data["families"]) self.log.debug( diff --git a/openpype/hosts/nuke/plugins/publish/extract_script_save.py b/openpype/hosts/nuke/plugins/publish/extract_script_save.py index 0c8e561fd7..e44e5686b6 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_script_save.py +++ b/openpype/hosts/nuke/plugins/publish/extract_script_save.py @@ -3,13 +3,12 @@ import pyblish.api class ExtractScriptSave(pyblish.api.Extractor): - """ - """ + """Save current Nuke workfile script""" label = 'Script Save' order = pyblish.api.Extractor.order - 0.1 hosts = ['nuke'] def process(self, instance): - self.log.info('saving script') + self.log.debug('Saving current script') nuke.scriptSave() diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 25262a7418..7befb7b7f3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -48,7 +48,7 @@ class ExtractSlateFrame(publish.Extractor): if instance.data.get("bakePresets"): for o_name, o_data in instance.data["bakePresets"].items(): - self.log.info("_ o_name: {}, o_data: {}".format( + self.log.debug("_ o_name: {}, o_data: {}".format( o_name, pformat(o_data))) self.render_slate( instance, @@ -65,14 +65,14 @@ class ExtractSlateFrame(publish.Extractor): def _create_staging_dir(self, instance): - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") staging_dir = os.path.normpath( os.path.dirname(instance.data["path"])) instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) def _check_frames_exists(self, instance): @@ -275,10 +275,10 @@ class ExtractSlateFrame(publish.Extractor): break if not matching_repre: - self.log.info(( - "Matching reresentaion was not found." + self.log.info( + "Matching reresentation was not found." " Representation files were not filled with slate." - )) + ) return # Add frame to matching representation files @@ -345,7 +345,7 @@ class ExtractSlateFrame(publish.Extractor): try: node[key].setValue(value) - self.log.info("Change key \"{}\" to value \"{}\"".format( + self.log.debug("Change key \"{}\" to value \"{}\"".format( key, value )) except NameError: diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 46288db743..de7567c1b1 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -69,7 +69,7 @@ class ExtractThumbnail(publish.Extractor): "bake_viewer_input_process"] node = instance.data["transientData"]["node"] # group node - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" not in instance.data: instance.data["representations"] = [] @@ -79,7 +79,7 @@ class ExtractThumbnail(publish.Extractor): instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) temporary_nodes = [] diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index ad60089952..761b080caa 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -43,8 +43,8 @@ class SelectCenterInNodeGraph(pyblish.api.Action): all_xC.append(xC) all_yC.append(yC) - self.log.info("all_xC: `{}`".format(all_xC)) - self.log.info("all_yC: `{}`".format(all_yC)) + self.log.debug("all_xC: `{}`".format(all_xC)) + self.log.debug("all_yC: `{}`".format(all_yC)) # zoom to nodes in node graph nuke.zoom(2, [min(all_xC), min(all_yC)]) diff --git a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py index 39114c80c8..ff6d73c6ec 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py +++ b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py @@ -104,9 +104,9 @@ class ValidateOutputResolution( _rfn["resize"].setValue(0) _rfn["black_outside"].setValue(1) - cls.log.info("I am adding reformat node") + cls.log.info("Adding reformat node") if cls.resolution_msg == invalid: reformat = cls.get_reformat(instance) reformat["format"].setValue(nuke.root()["format"].value()) - cls.log.info("I am fixing reformat to root.format") + cls.log.info("Fixing reformat to root.format") diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 9a35b61a0e..64bf69b69b 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -76,8 +76,8 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): return collections, remainder = clique.assemble(repre["files"]) - self.log.info("collections: {}".format(str(collections))) - self.log.info("remainder: {}".format(str(remainder))) + self.log.debug("collections: {}".format(str(collections))) + self.log.debug("remainder: {}".format(str(remainder))) collection = collections[0] @@ -103,15 +103,15 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): coll_start = min(collection.indexes) coll_end = max(collection.indexes) - self.log.info("frame_length: {}".format(frame_length)) - self.log.info("collected_frames_len: {}".format( + self.log.debug("frame_length: {}".format(frame_length)) + self.log.debug("collected_frames_len: {}".format( collected_frames_len)) - self.log.info("f_start_h-f_end_h: {}-{}".format( + self.log.debug("f_start_h-f_end_h: {}-{}".format( f_start_h, f_end_h)) - self.log.info( + self.log.debug( "coll_start-coll_end: {}-{}".format(coll_start, coll_end)) - self.log.info( + self.log.debug( "len(collection.indexes): {}".format(collected_frames_len) ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 9aae53e59d..9c8bfae388 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -39,7 +39,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action): set_node_knobs_from_settings(write_node, correct_data["knobs"]) - self.log.info("Node attributes were fixed") + self.log.debug("Node attributes were fixed") class ValidateNukeWriteNode( From ba804833cd42f7a78aa2095b68e0943dab7b81fc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 20:50:07 +0800 Subject: [PATCH 307/327] rename validate containers to validate instance has members --- .../plugins/publish/validate_containers.py | 4 ++-- .../publish/validate_no_max_content.py | 22 ------------------- 2 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 openpype/hosts/max/plugins/publish/validate_no_max_content.py diff --git a/openpype/hosts/max/plugins/publish/validate_containers.py b/openpype/hosts/max/plugins/publish/validate_containers.py index a5c0669a11..3c0039d5e0 100644 --- a/openpype/hosts/max/plugins/publish/validate_containers.py +++ b/openpype/hosts/max/plugins/publish/validate_containers.py @@ -3,8 +3,8 @@ import pyblish.api from openpype.pipeline import PublishValidationError -class ValidateContainers(pyblish.api.InstancePlugin): - """Validates Containers. +class ValidateInstanceHasMembers(pyblish.api.InstancePlugin): + """Validates Instance has members. Check if MaxScene containers includes any contents underneath. """ diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py deleted file mode 100644 index 73e12e75c9..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from openpype.pipeline import PublishValidationError -from pymxs import runtime as rt - - -class ValidateMaxContents(pyblish.api.InstancePlugin): - """Validates Max contents. - - Check if MaxScene container includes any contents underneath. - """ - - order = pyblish.api.ValidatorOrder - families = ["camera", - "maxScene", - "review"] - hosts = ["max"] - label = "Max Scene Contents" - - def process(self, instance): - if not instance.data["members"]: - raise PublishValidationError("No content found in the container") From 5914f2e23ce6ffdf24e1ec044bccd7d7144bd626 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 13 Oct 2023 15:54:12 +0200 Subject: [PATCH 308/327] :recycle: remove restriction for "Shot" folder type --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index c109a76a31..c39d1c3ee3 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -194,7 +194,7 @@ class CreateMultishotLayout(plugin.MayaCreator): parent_id = current_folder["id"] # get all child folders of the current one - child_folders = get_folders( + return get_folders( project_name=self.project_name, parent_ids=[parent_id], fields=[ @@ -203,7 +203,6 @@ class CreateMultishotLayout(plugin.MayaCreator): "name", "label", "path", "folderType", "id" ] ) - return [f for f in child_folders if f["folderType"] == "Shot"] # blast this creator if Ayon server is not enabled From 8f5a5341e000b8138ef6819cf42e238f3f57b8bf Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 13 Oct 2023 15:55:07 +0200 Subject: [PATCH 309/327] :recycle: improve error message --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index c39d1c3ee3..232ddc4389 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -114,7 +114,9 @@ class CreateMultishotLayout(plugin.MayaCreator): # want to create a new shot folders by publishing the layouts # and shot defined in the sequencer. Sort of editorial publish # in side of Maya. - raise CreatorError("No shots found under the specified folder.") + raise CreatorError(( + "No shots found under the specified " + f"folder: {pre_create_data['shotParent']}.")) # Get layout creator layout_creator_id = "io.openpype.creators.maya.layout" From 86c4dec6d2314122ccdd372e8818c82023d78c2f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 13 Oct 2023 16:04:06 +0200 Subject: [PATCH 310/327] :recycle: change warning message to debug --- openpype/hosts/maya/plugins/publish/extract_look.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index b2b3330df1..635c2c425c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -185,9 +185,9 @@ class MakeRSTexBin(TextureProcessor): "{}".format(config_path)) if not os.getenv("OCIO"): - self.log.warning( + self.log.debug( "OCIO environment variable not set." - "Setting it with OCIO config from OpenPype/AYON Settings." + "Setting it with OCIO config from Maya." ) os.environ["OCIO"] = config_path From 4ba25d35d3b8c8848bced077b9cc0f20b084bb32 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:20:33 +0800 Subject: [PATCH 311/327] rename the py script --- .../{validate_containers.py => validate_instance_has_members.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename openpype/hosts/max/plugins/publish/{validate_containers.py => validate_instance_has_members.py} (100%) diff --git a/openpype/hosts/max/plugins/publish/validate_containers.py b/openpype/hosts/max/plugins/publish/validate_instance_has_members.py similarity index 100% rename from openpype/hosts/max/plugins/publish/validate_containers.py rename to openpype/hosts/max/plugins/publish/validate_instance_has_members.py From ca915cc1957371e63f748af5cf00526460e0acd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 13 Oct 2023 16:40:10 +0200 Subject: [PATCH 312/327] Update openpype/hosts/nuke/plugins/publish/extract_camera.py --- openpype/hosts/nuke/plugins/publish/extract_camera.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index b0facd379a..5f9b5f154e 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -36,8 +36,6 @@ class ExtractCamera(publish.Extractor): step = 1 output_range = str(nuke.FrameRange(first_frame, last_frame, step)) - self.log.debug("instance.data: `{}`".format( - pformat(instance.data))) rm_nodes = [] self.log.debug("Creating additional nodes for 3D Camera Extractor") From 636c7e02fd98718e9ea2ee739c47b8714426f39c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 Oct 2023 16:40:33 +0200 Subject: [PATCH 313/327] removing empty row --- openpype/hosts/nuke/plugins/publish/extract_camera.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index 5f9b5f154e..3ec85c1f11 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -36,7 +36,6 @@ class ExtractCamera(publish.Extractor): step = 1 output_range = str(nuke.FrameRange(first_frame, last_frame, step)) - rm_nodes = [] self.log.debug("Creating additional nodes for 3D Camera Extractor") subset = instance.data["subset"] From 7dfc32f66ca5fcb0dbc76c56d7ac9448022fa53b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 23:19:48 +0800 Subject: [PATCH 314/327] bug fix on the project setting being errored out when passing through the validator and extractor --- openpype/hosts/max/plugins/publish/extract_pointcloud.py | 1 + openpype/hosts/max/plugins/publish/validate_pointcloud.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 583bbb6dbd..190f049d23 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -36,6 +36,7 @@ class ExtractPointCloud(publish.Extractor): label = "Extract Point Cloud" hosts = ["max"] families = ["pointcloud"] + settings = [] def process(self, instance): self.settings = self.get_setting(instance) diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index 295a23f1f6..a336cbd80c 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -100,8 +100,8 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): selection_list = instance.data["members"] - project_setting = instance.data["project_setting"] - attr_settings = project_setting["max"]["PointCloud"]["attribute"] + project_settings = instance.context.data["project_settings"] + attr_settings = project_settings["max"]["PointCloud"]["attribute"] for sel in selection_list: obj = sel.baseobject anim_names = rt.GetSubAnimNames(obj) From 94032f0522b90dcd94c63dd1d58177ce49ca1062 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 13 Oct 2023 17:39:29 +0200 Subject: [PATCH 315/327] :bug: convert generator to list --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 232ddc4389..9aabe43d8c 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -105,8 +105,8 @@ class CreateMultishotLayout(plugin.MayaCreator): ] def create(self, subset_name, instance_data, pre_create_data): - shots = self.get_related_shots( - folder_path=pre_create_data["shotParent"] + shots = list( + self.get_related_shots(folder_path=pre_create_data["shotParent"]) ) if not shots: # There are no shot folders under the specified folder. From 971164cd7ff29a52d865dfe2f58084eb9adeb13b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 13 Oct 2023 18:06:53 +0200 Subject: [PATCH 316/327] :bug: don't call cmds.ogs() if in headless mode --- openpype/hosts/maya/api/lib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 510d4ecc85..0c571d41e0 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -146,13 +146,15 @@ def suspended_refresh(suspend=True): cmds.ogs(pause=True) is a toggle so we cant pass False. """ - original_state = cmds.ogs(query=True, pause=True) + original_state = None + if not IS_HEADLESS: + original_state = cmds.ogs(query=True, pause=True) try: - if suspend and not original_state: + if suspend and not original_state and not IS_HEADLESS: cmds.ogs(pause=True) yield finally: - if suspend and not original_state: + if suspend and not original_state and not IS_HEADLESS: cmds.ogs(pause=True) From adc60f19ca118194d73d8fae646f305de5c189b5 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 14 Oct 2023 03:24:39 +0000 Subject: [PATCH 317/327] [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 b0a79162b2..f98d4c1cf5 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2" +__version__ = "3.17.3-nightly.1" From ac4ca2082fe342e1406d58f413df3479b59b4c16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Oct 2023 03:25:24 +0000 Subject: [PATCH 318/327] 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 25f36ebc9a..dba39ac36d 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.17.3-nightly.1 - 3.17.2 - 3.17.2-nightly.4 - 3.17.2-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.11-nightly.3 - 3.14.11-nightly.2 - 3.14.11-nightly.1 - - 3.14.10 validations: required: true - type: dropdown From cffe48fc205217c83bf0a402b325dfca11b30524 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Oct 2023 09:57:22 +0200 Subject: [PATCH 319/327] :recycle: simplify the code --- openpype/hosts/maya/api/lib.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 0c571d41e0..7c49c837e9 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -146,15 +146,17 @@ def suspended_refresh(suspend=True): cmds.ogs(pause=True) is a toggle so we cant pass False. """ - original_state = None - if not IS_HEADLESS: - original_state = cmds.ogs(query=True, pause=True) + if IS_HEADLESS: + yield + return + + original_state = cmds.ogs(query=True, pause=True) try: - if suspend and not original_state and not IS_HEADLESS: + if suspend and not original_state: cmds.ogs(pause=True) yield finally: - if suspend and not original_state and not IS_HEADLESS: + if suspend and not original_state: cmds.ogs(pause=True) From 5bcbf80d127d81b785382f7398836950547ff244 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Oct 2023 10:02:55 +0200 Subject: [PATCH 320/327] :recycle: remove unused code --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 9aabe43d8c..dae318512a 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -52,7 +52,6 @@ class CreateMultishotLayout(plugin.MayaCreator): current_path_parts = current_folder["path"].split("/") - items_with_label = [] # populate the list with parents of the current folder # this will create menu items like: # [ From 4bb51b91de8a903cc3540f3582cc22440fda60a0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Oct 2023 10:09:39 +0200 Subject: [PATCH 321/327] :bulb: rewrite todo comment to make it more clear --- openpype/hosts/maya/plugins/create/create_multishot_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index dae318512a..0b027c02ea 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -37,7 +37,7 @@ class CreateMultishotLayout(plugin.MayaCreator): # selected folder to create the Camera Sequencer. """ - Todo: get this needs to be switched to get_folder_by_path + Todo: `get_folder_by_name` should be switched to `get_folder_by_path` once the fork to pure AYON is done. Warning: this will not work for projects where the asset name From 9cd8c864eb10a0d646259fc18e96d9f21315ff5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 17:37:50 +0200 Subject: [PATCH 322/327] fix default factory of tools --- server_addon/applications/server/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server_addon/applications/server/settings.py b/server_addon/applications/server/settings.py index fd481b6ce8..be9a2ea07e 100644 --- a/server_addon/applications/server/settings.py +++ b/server_addon/applications/server/settings.py @@ -115,9 +115,7 @@ class ToolGroupModel(BaseSettingsModel): name: str = Field("", title="Name") label: str = Field("", title="Label") environment: str = Field("{}", title="Environments", widget="textarea") - variants: list[ToolVariantModel] = Field( - default_factory=ToolVariantModel - ) + variants: list[ToolVariantModel] = Field(default_factory=list) @validator("environment") def validate_json(cls, value): From 2afc95f514e80c73027a183ec46d3d0b237cd322 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 17:39:53 +0200 Subject: [PATCH 323/327] bump version --- server_addon/applications/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/applications/server/version.py b/server_addon/applications/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/applications/server/version.py +++ b/server_addon/applications/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 32ce0671323d053351d8f43aec0bd7e4f9e856a2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 17 Oct 2023 10:11:47 +0200 Subject: [PATCH 324/327] OP-7134 - added missing OPENPYPE_VERSION --- .../deadline/plugins/publish/submit_fusion_deadline.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index 0b97582d2a..9a718aa089 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -13,7 +13,8 @@ from openpype.pipeline.publish import ( ) from openpype.lib import ( BoolDef, - NumberDef + NumberDef, + is_running_from_build ) @@ -230,6 +231,11 @@ class FusionSubmitDeadline( "OPENPYPE_LOG_NO_COLORS", "IS_TEST" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) From 2e34fc444af468b7e46eb495346147f368be4542 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 10:53:21 +0200 Subject: [PATCH 325/327] skip tasks when looking for asset entity --- .../modules/ftrack/plugins/publish/collect_ftrack_api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py index fe3275ce2c..aade709360 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -194,10 +194,11 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): "TypedContext where project_id is \"{}\" and name in ({})" ).format(project_entity["id"], joined_asset_names)).all() - entities_by_name = { - entity["name"]: entity - for entity in entities - } + entities_by_name = {} + for entity in entities: + if entity.entity_type.lower() == "task": + continue + entities_by_name[entity["name"]] = entity for asset_name, by_task_data in instance_by_asset_and_task.items(): entity = entities_by_name.get(asset_name) From 9ce0ef1d9c2942bbaaf3920ce53ac217d3674bee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 11:31:40 +0200 Subject: [PATCH 326/327] use object type id to skip tasks --- .../plugins/publish/collect_ftrack_api.py | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py index aade709360..c78abbd1d6 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -44,19 +44,25 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): self.log.debug("Project found: {0}".format(project_entity)) + task_object_type = session.query( + "ObjectType where name is 'Task'").one() + task_object_type_id = task_object_type["id"] asset_entity = None if asset_name: # Find asset entity entity_query = ( - 'TypedContext where project_id is "{0}"' - ' and name is "{1}"' - ).format(project_entity["id"], asset_name) + "TypedContext where project_id is '{}'" + " and name is '{}'" + " and object_type_id != '{}'" + ).format( + project_entity["id"], + asset_name, + task_object_type_id + ) self.log.debug("Asset entity query: < {0} >".format(entity_query)) asset_entities = [] for entity in session.query(entity_query).all(): - # Skip tasks - if entity.entity_type.lower() != "task": - asset_entities.append(entity) + asset_entities.append(entity) if len(asset_entities) == 0: raise AssertionError(( @@ -103,10 +109,19 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): context.data["ftrackEntity"] = asset_entity context.data["ftrackTask"] = task_entity - self.per_instance_process(context, asset_entity, task_entity) + self.per_instance_process( + context, + asset_entity, + task_entity, + task_object_type_id + ) def per_instance_process( - self, context, context_asset_entity, context_task_entity + self, + context, + asset_entity, + task_entity, + task_object_type_id ): context_task_name = None context_asset_name = None @@ -182,24 +197,27 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): session = context.data["ftrackSession"] project_entity = context.data["ftrackProject"] - asset_names = set() - for asset_name in instance_by_asset_and_task.keys(): - asset_names.add(asset_name) + asset_names = set(instance_by_asset_and_task.keys()) joined_asset_names = ",".join([ "\"{}\"".format(name) for name in asset_names ]) - entities = session.query(( - "TypedContext where project_id is \"{}\" and name in ({})" - ).format(project_entity["id"], joined_asset_names)).all() - - entities_by_name = {} - for entity in entities: - if entity.entity_type.lower() == "task": - continue - entities_by_name[entity["name"]] = entity + entities = session.query( + ( + "TypedContext where project_id is \"{}\" and name in ({})" + " and object_type_id != '{}'" + ).format( + project_entity["id"], + joined_asset_names, + task_object_type_id + ) + ).all() + entities_by_name = { + entity["name"]: entity + for entity in entities + } for asset_name, by_task_data in instance_by_asset_and_task.items(): entity = entities_by_name.get(asset_name) task_entity_by_name = {} From 7c035a157e801869ead6c56193f9aa34a323dfe8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 12:52:46 +0200 Subject: [PATCH 327/327] fix args names --- openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py index c78abbd1d6..bea76718ca 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -119,8 +119,8 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): def per_instance_process( self, context, - asset_entity, - task_entity, + context_asset_entity, + context_task_entity, task_object_type_id ): context_task_name = None