From ca6f2b467ded88c0b0a8ac662f1e75145394f0a3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 24 Apr 2023 11:40:33 +0200 Subject: [PATCH 0001/1224] Add Fusion USD loader --- .../hosts/fusion/plugins/load/load_usd.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 openpype/hosts/fusion/plugins/load/load_usd.py diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py new file mode 100644 index 0000000000..8c2c69f52f --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -0,0 +1,73 @@ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) + + +class FusionLoadAlembicMesh(load.LoaderPlugin): + """Load USD into Fusion + + Support for USD was added since Fusion 18.5 + """ + + families = ["*"] + representations = ["*"] + extensions = {"usd", "usda", "usdz"} + + label = "Load USD" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "uLoader" + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["Filename"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["Filename"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() From 00b68805069207584dc955883268b3bbcab1005e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 30 May 2023 13:06:13 +0200 Subject: [PATCH 0002/1224] Fix Nuke workfile template builder so representations get loaded next to each other for a single placeholder --- .../maya/api/workfile_template_builder.py | 2 +- .../nuke/api/workfile_template_builder.py | 8 +++-- .../workfile/workfile_template_builder.py | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index 6e6166c2ef..504db4dc06 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -250,7 +250,7 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): def get_placeholder_options(self, options=None): return self.get_load_plugin_options(options) - def cleanup_placeholder(self, placeholder, failed): + def cleanup_placeholder(self, placeholder): """Hide placeholder, add them to placeholder set """ node = placeholder._scene_identifier diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 72d4ffb476..3def140c92 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -156,8 +156,10 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): ) return loaded_representation_ids - def _before_repre_load(self, placeholder, representation): + def _before_placeholder_load(self, placeholder): placeholder.data["nodes_init"] = nuke.allNodes() + + def _before_repre_load(self, placeholder, representation): placeholder.data["last_repre_id"] = str(representation["_id"]) def collect_placeholders(self): @@ -189,7 +191,7 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): def get_placeholder_options(self, options=None): return self.get_load_plugin_options(options) - def cleanup_placeholder(self, placeholder, failed): + def cleanup_placeholder(self, placeholder): # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) @@ -603,7 +605,7 @@ class NukePlaceholderCreatePlugin( def get_placeholder_options(self, options=None): return self.get_create_plugin_options(options) - def cleanup_placeholder(self, placeholder, failed): + def cleanup_placeholder(self, placeholder): # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 896ed40f2d..a8a7ffec75 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1457,8 +1457,15 @@ class PlaceholderLoadMixin(object): context_filters=context_filters )) + def _before_placeholder_load(self, placeholder): + """Can be overridden. It's called before placeholder representations + are loaded. + """ + + pass + def _before_repre_load(self, placeholder, representation): - """Can be overriden. Is called before representation is loaded.""" + """Can be overridden. It's called before representation is loaded.""" pass @@ -1491,7 +1498,7 @@ class PlaceholderLoadMixin(object): return output def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): - """Load placeholder is goind to load matching representations. + """Load placeholder is going to load matching representations. Note: Ignore repre ids is to avoid loading the same representation again @@ -1513,7 +1520,7 @@ class PlaceholderLoadMixin(object): # TODO check loader existence loader_name = placeholder.data["loader"] - loader_args = placeholder.data["loader_args"] + loader_args = self.parse_loader_args(placeholder.data["loader_args"]) placeholder_representations = self._get_representations(placeholder) @@ -1535,6 +1542,9 @@ class PlaceholderLoadMixin(object): self.project_name, filtered_representations ) loaders_by_name = self.builder.get_loaders_by_name() + self._before_placeholder_load( + placeholder + ) for repre_load_context in repre_load_contexts.values(): representation = repre_load_context["representation"] repre_context = representation["context"] @@ -1547,24 +1557,24 @@ class PlaceholderLoadMixin(object): repre_context["subset"], repre_context["asset"], loader_name, - loader_args + placeholder.data["loader_args"], ) ) try: container = load_with_repre_context( loaders_by_name[loader_name], repre_load_context, - options=self.parse_loader_args(loader_args) + options=loader_args ) except Exception: - failed = True self.load_failed(placeholder, representation) else: - failed = False self.load_succeed(placeholder, container) - self.cleanup_placeholder(placeholder, failed) + + # Cleanup placeholder after load of all representations + self.cleanup_placeholder(placeholder) def load_failed(self, placeholder, representation): if hasattr(placeholder, "load_failed"): @@ -1574,7 +1584,7 @@ class PlaceholderLoadMixin(object): if hasattr(placeholder, "load_succeed"): placeholder.load_succeed(container) - def cleanup_placeholder(self, placeholder, failed): + def cleanup_placeholder(self, placeholder): """Cleanup placeholder after load of single representation. Can be called multiple times during placeholder item populating and is @@ -1583,7 +1593,6 @@ class PlaceholderLoadMixin(object): Args: placeholder (PlaceholderItem): Item which was just used to load representation. - failed (bool): Loading of representation failed. """ pass From c253bc11d0af425744384ee6a1cb39bc9a8c0277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 30 May 2023 13:13:25 +0200 Subject: [PATCH 0003/1224] Fix docstring --- openpype/pipeline/workfile/workfile_template_builder.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index a8a7ffec75..a081b66789 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1585,10 +1585,7 @@ class PlaceholderLoadMixin(object): placeholder.load_succeed(container) def cleanup_placeholder(self, placeholder): - """Cleanup placeholder after load of single representation. - - Can be called multiple times during placeholder item populating and is - called even if loading failed. + """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load From ac7e53a44a038df8c31ad1fbd4d49c8c90c3a95b Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 3 Jul 2023 16:55:53 -0500 Subject: [PATCH 0004/1224] Set some default `failed` value on the right scope --- openpype/pipeline/workfile/workfile_template_builder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 13fcd3693e..8853097b21 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1547,6 +1547,8 @@ class PlaceholderLoadMixin(object): self._before_placeholder_load( placeholder ) + + failed = False for repre_load_context in repre_load_contexts.values(): representation = repre_load_context["representation"] repre_context = representation["context"] @@ -1571,9 +1573,10 @@ class PlaceholderLoadMixin(object): except Exception: self.load_failed(placeholder, representation) - + failed = True else: self.load_succeed(placeholder, container) + # Run post placeholder process after load of all representations self.post_placeholder_process(placeholder, failed) From 833d2fcb737aa6e8e561a927a93ee60de6225014 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 3 Jul 2023 17:01:10 -0500 Subject: [PATCH 0005/1224] Fix docstrings --- .../hosts/maya/api/workfile_template_builder.py | 8 +++++++- .../hosts/nuke/api/workfile_template_builder.py | 14 ++++++++++++++ .../pipeline/workfile/workfile_template_builder.py | 10 ++-------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index e2f30f46d0..30113578c5 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -244,8 +244,14 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): - """Hide placeholder, add them to placeholder set + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. """ + # Hide placeholder and add them to placeholder set node = placeholder.scene_identifier cmds.sets(node, addElement=PLACEHOLDER_SET) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 7fc1ff385b..672c5cb836 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -193,6 +193,13 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) @@ -607,6 +614,13 @@ class NukePlaceholderCreatePlugin( return self.get_create_plugin_options(options) def post_placeholder_process(self, placeholder, failed): + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 8853097b21..8ef83a4cdd 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1598,10 +1598,7 @@ class PlaceholderLoadMixin(object): placeholder.load_succeed(container) def post_placeholder_process(self, placeholder, failed): - """Cleanup placeholder after load of single representation. - - Can be called multiple times during placeholder item populating and is - called even if loading failed. + """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load @@ -1788,10 +1785,7 @@ class PlaceholderCreateMixin(object): placeholder.create_succeed(creator_instance) def post_placeholder_process(self, placeholder, failed): - """Cleanup placeholder after load of single representation. - - Can be called multiple times during placeholder item populating and is - called even if loading failed. + """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load From 9847f879d827a56108348bae27985fd25316ffdc Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 3 Jul 2023 17:03:23 -0500 Subject: [PATCH 0006/1224] Revert to original docstring --- openpype/pipeline/workfile/workfile_template_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 8ef83a4cdd..3b06ef059b 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1603,7 +1603,7 @@ class PlaceholderLoadMixin(object): Args: placeholder (PlaceholderItem): Item which was just used to load representation. - failed (bool): True if loading failed. + failed (bool): Loading of representation failed. """ pass From 8e281f4efef4917fbe965f7e49979577e4e6e226 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jul 2023 13:17:30 +0200 Subject: [PATCH 0007/1224] simplification of subprocess calls --- openpype/pipeline/colorspace.py | 65 +++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3f2d4891c1..2ca78f3520 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -206,8 +206,9 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): return True +# TODO: remove this in future - backward compatibility def get_data_subprocess(config_path, data_type): - """Get data via subprocess + """[Deprecated] Get data via subprocess Wrapper for Python 2 hosts. @@ -221,7 +222,6 @@ def get_data_subprocess(config_path, data_type): "config", data_type, "--in_path", config_path, "--out_path", tmp_json_path - ] log.info("Executing: {}".format(" ".join(args))) @@ -236,6 +236,45 @@ def get_data_subprocess(config_path, data_type): return json.loads(return_json_data) +def get_wrapped_with_subprocess(command_group, command, **kwargs): + """Get data via subprocess + + Wrapper for Python 2 hosts. + + Args: + command_group (str): command group name + command (str): command name + **kwargs: command arguments + + Returns: + Any[dict, None]: data + """ + with _make_temp_json_file() as tmp_json_path: + # Prepare subprocess arguments + args = [ + "run", get_ocio_config_script_path(), + command_group, command + ] + + for key_, value_ in kwargs.items(): + args.extend(("--{}".format(key_), value_)) + + args.append("--out_path") + args.append(tmp_json_path) + + log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": log + } + + run_openpype_process(*args, **process_kwargs) + + # return all colorspaces + return_json_data = open(tmp_json_path).read() + return json.loads(return_json_data) + + def compatibility_check(): """Making sure PyOpenColorIO is importable""" try: @@ -260,15 +299,18 @@ def get_ocio_config_colorspaces(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - return get_colorspace_data_subprocess(config_path) + return get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) from openpype.scripts.ocio_wrapper import _get_colorspace_data return _get_colorspace_data(config_path) +# TODO: remove this in future - backward compatibility def get_colorspace_data_subprocess(config_path): - """Get colorspace data via subprocess + """[Deprecated] Get colorspace data via subprocess Wrapper for Python 2 hosts. @@ -278,7 +320,9 @@ def get_colorspace_data_subprocess(config_path): Returns: dict: colorspace and family in couple """ - return get_data_subprocess(config_path, "get_colorspace") + return get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) def get_ocio_config_views(config_path): @@ -296,15 +340,18 @@ def get_ocio_config_views(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - return get_views_data_subprocess(config_path) + return get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) from openpype.scripts.ocio_wrapper import _get_views_data return _get_views_data(config_path) +# TODO: remove this in future - backward compatibility def get_views_data_subprocess(config_path): - """Get viewers data via subprocess + """[Deprecated] Get viewers data via subprocess Wrapper for Python 2 hosts. @@ -314,7 +361,9 @@ def get_views_data_subprocess(config_path): Returns: dict: `display/viewer` and viewer data """ - return get_data_subprocess(config_path, "get_views") + return get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) def get_imageio_config( From 739c2e15bc58c447cf25ef9a9f9204a0aaf05344 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jul 2023 13:18:34 +0200 Subject: [PATCH 0008/1224] adding support for OCIO v2 file rules --- openpype/pipeline/colorspace.py | 31 ++++++++++ openpype/scripts/ocio_wrapper.py | 99 ++++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 2ca78f3520..26e12871f8 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -132,6 +132,37 @@ def get_imageio_colorspace_from_filepath( return colorspace_name +def get_colorspace_from_filepath(config_path, filepath): + """Get colorspace from file path wrapper. + + Wrapper function for getting colorspace from file path. + + Args: + config_path (str): path leading to config.ocio file + filepath (str): path leading to a file + + Returns: + Any[str, None]: matching colorspace name + """ + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + result_data = get_wrapped_with_subprocess( + "colorspace", "get_colorspace_from_filepath", + config_path=config_path, + filepath=filepath + ) + if result_data: + return result_data[0] + + from openpype.scripts.ocio_wrapper import _get_colorspace_from_filepath + + result_data = _get_colorspace_from_filepath(config_path, filepath) + + if result_data: + return result_data[0] + + def parse_colorspace_from_filepath( path, host_name, project_name, config_data=None, diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 16558642c6..1c86216347 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -27,7 +27,7 @@ import PyOpenColorIO as ocio @click.group() def main(): - pass + pass # noqa: WPS100 @main.group() @@ -37,7 +37,17 @@ def config(): Example of use: > pyton.exe ./ocio_wrapper.py config *args """ - pass + pass # noqa: WPS100 + + +@main.group() +def colorspace(): + """Colorspace related commands group + + Example of use: + > pyton.exe ./ocio_wrapper.py config *args + """ + pass # noqa: WPS100 @config.command( @@ -70,8 +80,8 @@ def get_colorspace(in_path, out_path): out_data = _get_colorspace_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Colorspace data are saved to '{json_path}'") @@ -97,8 +107,8 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) return { - c.getName(): c.getFamily() - for c in config.getColorSpaces() + c_.getName(): c_.getFamily() + for c_ in config.getColorSpaces() } @@ -132,8 +142,8 @@ def get_views(in_path, out_path): out_data = _get_views_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Viewer data are saved to '{json_path}'") @@ -157,7 +167,7 @@ def _get_views_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - data = {} + data_ = {} for display in config.getDisplays(): for view in config.getViews(display): colorspace = config.getDisplayViewColorSpaceName(display, view) @@ -165,13 +175,80 @@ def _get_views_data(config_path): if colorspace == "": colorspace = display - data[f"{display}/{view}"] = { + data_[f"{display}/{view}"] = { "display": display, "view": view, "colorspace": colorspace } - return data + return data_ + + +@colorspace.command( + name="get_colorspace_from_filepath", + help=( + "return colorspace from filepath " + "--config_path - ocio config file path (input arg is required) " + "--filepath - any file path (input arg is required) " + "--out_path - temp json file path (input arg is required)" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--filepath", required=True, + help="path to file to get colorspace from", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_colorspace_from_filepath(config_path, filepath, out_path): + """Get colorspace from file path wrapper. + + Python 2 wrapped console command + + Args: + config_path (str): config file path string + filepath (str): path string leading to file + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py colorspace get_colorspace_from_filepath \ + --config_path= --filepath= --out_path= + """ + json_path = Path(out_path) + + colorspace = _get_colorspace_from_filepath(config_path, filepath) + + with open(json_path, "w") as f_: + json.dump(colorspace, f_) + + print(f"Colorspace name is saved to '{json_path}'") + + +def _get_colorspace_from_filepath(config_path, filepath): + """Return found colorspace data found in v2 file rules. + + Args: + config_path (str): path string leading to config.ocio + filepath (str): path string leading to v2 file rules + + Raises: + IOError: Input config does not exist. + + Returns: + dict: aggregated available colorspaces + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError( + f"Input path `{config_path}` should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + colorspace = config.getColorSpaceFromFilepath(str(filepath)) + + return colorspace if __name__ == '__main__': From 0114993456108652b8995ac87531462e79e065b6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jul 2023 13:57:20 +0200 Subject: [PATCH 0009/1224] compatibility to config version --- openpype/pipeline/colorspace.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 26e12871f8..0b23c2b4e3 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -124,6 +124,10 @@ def get_imageio_colorspace_from_filepath( )) return None + if compatibility_check_config_version(config_data["path"], major=2): + colorspace_name = get_colorspace_from_filepath( + config_data["path"], path) + # validate matching colorspace with config if validate and config_data: validate_imageio_colorspace_in_config( @@ -312,6 +316,32 @@ def compatibility_check(): import PyOpenColorIO # noqa: F401 except ImportError: return False + + # compatible + return True + + +def compatibility_check_config_version(config_path, major=1, minor=None): + """Making sure PyOpenColorIO config version is compatible""" + try: + import PyOpenColorIO as ocio + config = ocio.Config().CreateFromFile(str(config_path)) + + config_version_major = config.getMajorVersion() + config_version_minor = config.getMinorVersion() + print(config_version_major, config_version_minor) + + # check major version + if config_version_major != major: + return False + # check minor version + if minor and config_version_minor != minor: + return False + + except ImportError: + return False + + # compatible return True From 9c18402ac70952a39247d681322777951b1fe97c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jul 2023 16:42:52 +0200 Subject: [PATCH 0010/1224] updating config version validation - cashing python version validation so it is overwritable by tests - implementing python compatibility override into tests - adding tests for ocio v2 filerules --- openpype/pipeline/colorspace.py | 52 +++++++++------ openpype/scripts/ocio_wrapper.py | 64 +++++++++++++++++- .../unit/openpype/pipeline/test_colorspace.py | 65 +++++++++++++++++++ 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 0b23c2b4e3..72c140195e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -19,6 +19,7 @@ log = Logger.get_logger(__name__) class CashedData: remapping = None + python3compatible = None @contextlib.contextmanager @@ -118,16 +119,23 @@ def get_imageio_colorspace_from_filepath( if ext_match and file_match: colorspace_name = file_rule["colorspace"] + # if no file rule matched, try to get colorspace + # from filepath with OCIO v2 way + # QUESTION: should we override file rules from our settings and + # in ocio v2 only focus on file rules set in config file? + if ( + compatibility_check_config_version(config_data["path"], major=2) + and not colorspace_name + ): + colorspace_name = get_colorspace_from_filepath( + config_data["path"], path) + if not colorspace_name: log.info("No imageio file rule matched input path: '{}'".format( path )) return None - if compatibility_check_config_version(config_data["path"], major=2): - colorspace_name = get_colorspace_from_filepath( - config_data["path"], path) - # validate matching colorspace with config if validate and config_data: validate_imageio_colorspace_in_config( @@ -312,33 +320,39 @@ def get_wrapped_with_subprocess(command_group, command, **kwargs): def compatibility_check(): """Making sure PyOpenColorIO is importable""" + if CashedData.python3compatible is not None: + return CashedData.python3compatible + try: import PyOpenColorIO # noqa: F401 + CashedData.python3compatible = True except ImportError: - return False + CashedData.python3compatible = False # compatible - return True + return CashedData.python3compatible def compatibility_check_config_version(config_path, major=1, minor=None): """Making sure PyOpenColorIO config version is compatible""" - try: - import PyOpenColorIO as ocio - config = ocio.Config().CreateFromFile(str(config_path)) - config_version_major = config.getMajorVersion() - config_version_minor = config.getMinorVersion() - print(config_version_major, config_version_minor) + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + version_data = get_wrapped_with_subprocess( + "config", "get_version", config_path=config_path + ) - # check major version - if config_version_major != major: - return False - # check minor version - if minor and config_version_minor != minor: - return False + from openpype.scripts.ocio_wrapper import _get_version_data - except ImportError: + version_data = _get_version_data(config_path) + + # check major version + if version_data["major"] != major: + return False + + # check minor version + if minor and version_data["minor"] != minor: return False # compatible diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 1c86216347..4332ea5b01 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -184,6 +184,68 @@ def _get_views_data(config_path): return data_ +@config.command( + name="get_version", + help=( + "return major and minor version from config file " + "--config_path input arg is required" + "--out_path input arg is required" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_version(config_path, out_path): + """Get version of config. + + Python 2 wrapped console command + + Args: + config_path (str): ocio config file path string + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py config get_version \ + --config_path= --out_path= + """ + json_path = Path(out_path) + + out_data = _get_version_data(config_path) + + with open(json_path, "w") as f_: + json.dump(out_data, f_) + + print(f"Config version data are saved to '{json_path}'") + + +def _get_version_data(config_path): + """Return major and minor version info. + + Args: + config_path (str): path string leading to config.ocio + + Raises: + IOError: Input config does not exist. + + Returns: + dict: minor and major keys with values + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + + return { + "major": config.getMajorVersion(), + "minor": config.getMinorVersion() + } + + @colorspace.command( name="get_colorspace_from_filepath", help=( @@ -198,7 +260,7 @@ def _get_views_data(config_path): type=click.Path(exists=True)) @click.option("--filepath", required=True, help="path to file to get colorspace from", - type=click.Path(exists=True)) + type=click.Path()) @click.option("--out_path", required=True, help="path where to write output json file", type=click.Path()) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index c22acee2d4..a6fcc68055 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -185,5 +185,70 @@ class TestPipelineColorspace(TestPipeline): assert expected_hiero == hiero_file_rules, ( f"Not matching file rules {expected_hiero}") + def test_get_imageio_colorspace_from_filepath_p3(self, project_settings): + """Test Colorspace from filepath with python 3 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + nuke_colorspace = colorspace.get_imageio_colorspace_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_imageio_colorspace_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + def test_get_imageio_colorspace_from_filepath_python2mode( + self, project_settings): + """Test Colorspace from filepath with python 2 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + # switch to python 2 compatibility mode + colorspace.CashedData.python3compatible = False + + nuke_colorspace = colorspace.get_imageio_colorspace_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_imageio_colorspace_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + # return to python 3 compatibility mode + colorspace.CashedData.python3compatible = None + test_case = TestPipelineColorspace() From edc260073b62afb56c2304e747bb739274665630 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jul 2023 16:49:22 +0200 Subject: [PATCH 0011/1224] updating testing package gdrive hash --- tests/unit/openpype/pipeline/test_colorspace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index a6fcc68055..e63ca510f2 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -26,12 +26,12 @@ class TestPipelineColorspace(TestPipeline): Example: cd to OpenPype repo root dir - poetry run python ./start.py runtests ../tests/unit/openpype/pipeline - """ + poetry run python ./start.py runtests /tests/unit/openpype/pipeline/test_colorspace.py + """ # noqa: E501 TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) From 97477d2049f8b5493ef0c7f03aaceb6e3ea9a034 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jul 2023 16:11:54 +0200 Subject: [PATCH 0012/1224] todos for parseColorSpaceFromString --- openpype/pipeline/colorspace.py | 2 ++ openpype/scripts/ocio_wrapper.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 72c140195e..13b235d5dd 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -123,6 +123,8 @@ def get_imageio_colorspace_from_filepath( # from filepath with OCIO v2 way # QUESTION: should we override file rules from our settings and # in ocio v2 only focus on file rules set in config file? + # TODO: do the ocio v compatibility check inside of wrapper script + # because of implementation `parseColorSpaceFromString` if ( compatibility_check_config_version(config_data["path"], major=2) and not colorspace_name diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 4332ea5b01..1feedde627 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -308,6 +308,8 @@ def _get_colorspace_from_filepath(config_path, filepath): f"Input path `{config_path}` should be `config.ocio` file") config = ocio.Config().CreateFromFile(str(config_path)) + + # TODO: use `parseColorSpaceFromString` instead if ocio v1 colorspace = config.getColorSpaceFromFilepath(str(filepath)) return colorspace From 08d17e527443fab05731eb0428806568e8e297aa Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 15:44:19 +0200 Subject: [PATCH 0013/1224] Added possibility to discard changes with comp_lock_and_undo_chunk() --- openpype/hosts/fusion/api/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index d96557571b..56705b1a40 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -354,7 +354,11 @@ def get_current_comp(): @contextlib.contextmanager -def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): +def comp_lock_and_undo_chunk( + comp, + undo_queue_name="Script CMD", + keep_undo=True, +): """Lock comp and open an undo chunk during the context""" try: comp.Lock() @@ -362,4 +366,4 @@ def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): yield finally: comp.Unlock() - comp.EndUndo() + comp.EndUndo(keep_undo) From 6a3729c22923ad76b205a8f750501c7b6c6e7336 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 15:44:38 +0200 Subject: [PATCH 0014/1224] Add resolution validator --- .../publish/validator_saver_resolution.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py new file mode 100644 index 0000000000..43e2ea5093 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py @@ -0,0 +1,96 @@ +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin, +) + +from openpype.hosts.fusion.api.action import SelectInvalidAction +from openpype.hosts.fusion.api import ( + get_current_comp, + comp_lock_and_undo_chunk, +) + + +class ValidateSaverResolution( + pyblish.api.InstancePlugin, OptionalPyblishPluginMixin +): + """Validate that the saver input resolution matches the projects""" + + order = pyblish.api.ValidatorOrder + label = "Validate Saver Resolution" + families = ["render"] + hosts = ["fusion"] + optional = True + actions = [SelectInvalidAction] + + @classmethod + def get_invalid(cls, instance, wrong_resolution=None): + """Validate if the saver rescive the expected resolution""" + if wrong_resolution is None: + wrong_resolution = [] + + saver = instance[0] + firstFrame = saver.GetAttrs("TOOLNT_Region_Start")[1] + comp = get_current_comp() + + # If the current saver hasn't bin rendered its input resolution + # hasn't bin saved. To combat this, add an expression in + # the comments field to read the resolution + + # False undo removes the undo-stack from the undo list + with comp_lock_and_undo_chunk(comp, "Read resolution", False): + # Save old comment + oldComment = "" + hasExpression = False + if saver["Comments"][firstFrame] != "": + if saver["Comments"].GetExpression() is not None: + hasExpression = True + oldComment = saver["Comments"].GetExpression() + saver["Comments"].SetExpression(None) + else: + oldComment = saver["Comments"][firstFrame] + saver["Comments"][firstFrame] = "" + + # Get input width + saver["Comments"].SetExpression("self.Input.OriginalWidth") + width = int(saver["Comments"][firstFrame]) + + # Get input height + saver["Comments"].SetExpression("self.Input.OriginalHeight") + height = int(saver["Comments"][firstFrame]) + + # Reset old comment + saver["Comments"].SetExpression(None) + if hasExpression: + saver["Comments"].SetExpression(oldComment) + else: + saver["Comments"][firstFrame] = oldComment + + # Time to compare! + wrong_resolution.append("{}x{}".format(width, height)) + entityData = instance.data["assetEntity"]["data"] + if entityData["resolutionWidth"] != width: + return [saver] + if entityData["resolutionHeight"] != height: + return [saver] + + return [] + + def process(self, instance): + if not self.is_active(instance.data): + return + + wrong_resolution = [] + invalid = self.get_invalid(instance, wrong_resolution) + if invalid: + entityData = instance.data["assetEntity"]["data"] + raise PublishValidationError( + "The input's resolution does not match" + " the asset's resolution of {}x{}.\n\n" + "The input's resolution is {}".format( + entityData["resolutionWidth"], + entityData["resolutionHeight"], + wrong_resolution[0], + ), + title=self.label, + ) From 644897a89d81a5be31371bde9d03a81b92e6b8f8 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 15:44:50 +0200 Subject: [PATCH 0015/1224] Black formatting --- openpype/hosts/fusion/api/lib.py | 106 +++++++++++++++++++------------ 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 56705b1a40..19db484856 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -22,8 +22,14 @@ self = sys.modules[__name__] self._project = None -def update_frame_range(start, end, comp=None, set_render_range=True, - handle_start=0, handle_end=0): +def update_frame_range( + start, + end, + comp=None, + set_render_range=True, + handle_start=0, + handle_end=0, +): """Set Fusion comp's start and end frame range Args: @@ -49,15 +55,17 @@ def update_frame_range(start, end, comp=None, set_render_range=True, attrs = { "COMPN_GlobalStart": start - handle_start, - "COMPN_GlobalEnd": end + handle_end + "COMPN_GlobalEnd": end + handle_end, } # set frame range if set_render_range: - attrs.update({ - "COMPN_RenderStart": start, - "COMPN_RenderEnd": end - }) + attrs.update( + { + "COMPN_RenderStart": start, + "COMPN_RenderEnd": end, + } + ) with comp_lock_and_undo_chunk(comp): comp.SetAttrs(attrs) @@ -70,9 +78,13 @@ def set_asset_framerange(): end = asset_doc["data"]["frameEnd"] handle_start = asset_doc["data"]["handleStart"] handle_end = asset_doc["data"]["handleEnd"] - update_frame_range(start, end, set_render_range=True, - handle_start=handle_start, - handle_end=handle_end) + update_frame_range( + start, + end, + set_render_range=True, + handle_start=handle_start, + handle_end=handle_end, + ) def set_asset_resolution(): @@ -82,12 +94,15 @@ def set_asset_resolution(): height = asset_doc["data"]["resolutionHeight"] comp = get_current_comp() - print("Setting comp frame format resolution to {}x{}".format(width, - height)) - comp.SetPrefs({ - "Comp.FrameFormat.Width": width, - "Comp.FrameFormat.Height": height, - }) + print( + "Setting comp frame format resolution to {}x{}".format(width, height) + ) + comp.SetPrefs( + { + "Comp.FrameFormat.Width": width, + "Comp.FrameFormat.Height": height, + } + ) def validate_comp_prefs(comp=None, force_repair=False): @@ -108,7 +123,7 @@ def validate_comp_prefs(comp=None, force_repair=False): "data.fps", "data.resolutionWidth", "data.resolutionHeight", - "data.pixelAspect" + "data.pixelAspect", ] asset_doc = get_current_project_asset(fields=fields) asset_data = asset_doc["data"] @@ -125,7 +140,7 @@ def validate_comp_prefs(comp=None, force_repair=False): ("resolutionWidth", "Width", "Resolution Width"), ("resolutionHeight", "Height", "Resolution Height"), ("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"), - ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y") + ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y"), ] invalid = [] @@ -133,9 +148,9 @@ def validate_comp_prefs(comp=None, force_repair=False): asset_value = asset_data[key] comp_value = comp_frame_format_prefs.get(comp_key) if asset_value != comp_value: - invalid_msg = "{} {} should be {}".format(label, - comp_value, - asset_value) + invalid_msg = "{} {} should be {}".format( + label, comp_value, asset_value + ) invalid.append(invalid_msg) if not force_repair: @@ -146,7 +161,8 @@ def validate_comp_prefs(comp=None, force_repair=False): pref=label, value=comp_value, asset_name=asset_doc["name"], - asset_value=asset_value) + asset_value=asset_value, + ) ) if invalid: @@ -167,6 +183,7 @@ def validate_comp_prefs(comp=None, force_repair=False): from . import menu from openpype.widgets import popup from openpype.style import load_stylesheet + dialog = popup.Popup(parent=menu.menu) dialog.setWindowTitle("Fusion comp has invalid configuration") @@ -181,10 +198,12 @@ def validate_comp_prefs(comp=None, force_repair=False): dialog.setStyleSheet(load_stylesheet()) -def switch_item(container, - asset_name=None, - subset_name=None, - representation_name=None): +def switch_item( + container, + asset_name=None, + subset_name=None, + representation_name=None, +): """Switch container asset, subset or representation of a container by name. It'll always switch to the latest version - of course a different @@ -211,7 +230,8 @@ def switch_item(container, repre_id = container["representation"] representation = get_representation_by_id(project_name, repre_id) repre_parent_docs = get_representation_parents( - project_name, representation) + project_name, representation + ) if repre_parent_docs: version, subset, asset, _ = repre_parent_docs else: @@ -228,14 +248,18 @@ def switch_item(container, # Find the new one asset = get_asset_by_name(project_name, asset_name, fields=["_id"]) - assert asset, ("Could not find asset in the database with the name " - "'%s'" % asset_name) + assert asset, ( + "Could not find asset in the database with the name " + "'%s'" % asset_name + ) subset = get_subset_by_name( project_name, subset_name, asset["_id"], fields=["_id"] ) - assert subset, ("Could not find subset in the database with the name " - "'%s'" % subset_name) + assert subset, ( + "Could not find subset in the database with the name " + "'%s'" % subset_name + ) version = get_last_version_by_subset_id( project_name, subset["_id"], fields=["_id"] @@ -247,8 +271,10 @@ def switch_item(container, representation = get_representation_by_name( project_name, representation_name, version["_id"] ) - assert representation, ("Could not find representation in the database " - "with the name '%s'" % representation_name) + assert representation, ( + "Could not find representation in the database " + "with the name '%s'" % representation_name + ) switch_container(container, representation) @@ -273,11 +299,13 @@ def maintained_selection(comp=None): @contextlib.contextmanager -def maintained_comp_range(comp=None, - global_start=True, - global_end=True, - render_start=True, - render_end=True): +def maintained_comp_range( + comp=None, + global_start=True, + global_end=True, + render_start=True, + render_end=True, +): """Reset comp frame ranges from before the context after the context""" if comp is None: comp = get_current_comp() @@ -321,7 +349,7 @@ def get_frame_path(path): filename, ext = os.path.splitext(path) # Find a final number group - match = re.match('.*?([0-9]+)$', filename) + match = re.match(".*?([0-9]+)$", filename) if match: padding = len(match.group(1)) # remove number from end since fusion From 0cd2095af326a480d5d9dacfb409e7df3d8b412a Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Thu, 20 Jul 2023 17:08:30 +0200 Subject: [PATCH 0016/1224] 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 0017/1224] 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 0018/1224] 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 0019/1224] 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 0020/1224] 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 0021/1224] 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 0022/1224] 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 0023/1224] 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 0024/1224] 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 0025/1224] 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 0026/1224] 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 0027/1224] 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 0028/1224] 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 0029/1224] 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 0030/1224] 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 0031/1224] 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 0032/1224] 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 0033/1224] 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 0034/1224] 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 0035/1224] 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 0036/1224] 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 0037/1224] 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 0038/1224] 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 0039/1224] 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 0040/1224] 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 0041/1224] 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 0042/1224] 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 0043/1224] 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 44d8327aa9c92246c3aec2e588034bad9d83c27c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 12:43:28 +0200 Subject: [PATCH 0044/1224] adding deprecated decorators --- openpype/pipeline/colorspace.py | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 02a6b90f25..c90fb299f9 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -2,9 +2,12 @@ from copy import deepcopy import re import os import json -import platform import contextlib +import functools +import platform import tempfile +import warnings + from openpype import PACKAGE_DIR from openpype.settings import get_project_settings from openpype.lib import ( @@ -22,6 +25,51 @@ class CashedData: python3compatible = None +class DeprecatedWarning(DeprecationWarning): + pass + + +def deprecated(new_destination): + """Mark functions as deprecated. + + It will result in a warning being emitted when the function is used. + """ + + func = None + if callable(new_destination): + func = new_destination + new_destination = None + + def _decorator(decorated_func): + if new_destination is None: + warning_message = ( + " Please check content of deprecated function to figure out" + " possible replacement." + ) + else: + warning_message = " Please replace your usage with '{}'.".format( + new_destination + ) + + @functools.wraps(decorated_func) + def wrapper(*args, **kwargs): + warnings.simplefilter("always", DeprecatedWarning) + warnings.warn( + ( + "Call to deprecated function '{}'" + "\nFunction was moved or removed.{}" + ).format(decorated_func.__name__, warning_message), + category=DeprecatedWarning, + stacklevel=4 + ) + return decorated_func(*args, **kwargs) + return wrapper + + if func is None: + return _decorator + return _decorator(func) + + @contextlib.contextmanager def _make_temp_json_file(): """Wrapping function for json temp file @@ -252,6 +300,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): # TODO: remove this in future - backward compatibility +@deprecated("get_wrapped_with_subprocess") def get_data_subprocess(config_path, data_type): """[Deprecated] Get data via subprocess @@ -386,6 +435,7 @@ def get_ocio_config_colorspaces(config_path): # TODO: remove this in future - backward compatibility +@deprecated("get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): """[Deprecated] Get colorspace data via subprocess @@ -427,6 +477,7 @@ def get_ocio_config_views(config_path): # TODO: remove this in future - backward compatibility +@deprecated("get_wrapped_with_subprocess") def get_views_data_subprocess(config_path): """[Deprecated] Get viewers data via subprocess From c2212a6cc111f9499b41701dcbad0e1b5809ae07 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 13:21:24 +0200 Subject: [PATCH 0045/1224] revert unreal submodule --- openpype/hosts/unreal/integration | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/integration b/openpype/hosts/unreal/integration index ff15c70077..63266607ce 160000 --- a/openpype/hosts/unreal/integration +++ b/openpype/hosts/unreal/integration @@ -1 +1 @@ -Subproject commit ff15c700771e719cc5f3d561ac5d6f7590623986 +Subproject commit 63266607ceb972a61484f046634ddfc9eb0b5757 From e10ca74b1b93ddebb9786b04e22dde1d6de137c4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 14:00:52 +0200 Subject: [PATCH 0046/1224] changing signature of `parse_colorspace_from_filepath` --- openpype/pipeline/colorspace.py | 47 ++++++++++--------- .../unit/openpype/pipeline/test_colorspace.py | 4 +- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index c90fb299f9..9ea9d1f888 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -171,8 +171,6 @@ def get_imageio_colorspace_from_filepath( # from filepath with OCIO v2 way # QUESTION: should we override file rules from our settings and # in ocio v2 only focus on file rules set in config file? - # TODO: do the ocio v compatibility check inside of wrapper script - # because of implementation `parseColorSpaceFromString` if ( compatibility_check_config_version(config_data["path"], major=2) and not colorspace_name @@ -226,51 +224,56 @@ def get_colorspace_from_filepath(config_path, filepath): def parse_colorspace_from_filepath( - path, host_name, project_name, - config_data=None, - project_settings=None + filepath, colorspaces=None, config_path=None ): - """Parse colorspace name from filepath + """Parse colorspace name from list of filepaths An input path can have colorspace name used as part of name or as folder name. + # add example python code block + + Example: + >>> config_path = "path/to/config.ocio" + >>> colorspaces = get_ocio_config_colorspaces(config_path) + >>> colorspace = parse_colorspace_from_filepath( + "path/to/file/acescg/file.exr", + colorspaces=colorspaces + ) + >>> print(colorspace) + acescg + Args: - path (str): path string - host_name (str): host name - project_name (str): project name - config_data (dict, optional): config path and template in dict. - Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. + filepath (str): path string + colorspaces (Optional[dict[str]]): list of colorspaces + config_path (Optional[str]): path to config.ocio file Returns: str: name of colorspace """ - if not config_data: - project_settings = project_settings or get_project_settings( - project_name + if not colorspaces and not config_path: + raise ValueError( + "You need to provide `config_path` if you don't " + "want to provide input `colorspaces`." ) - config_data = get_imageio_config( - project_name, host_name, project_settings) - config_path = config_data["path"] + colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) # match file rule from path colorspace_name = None - colorspaces = get_ocio_config_colorspaces(config_path) for colorspace_key in colorspaces: # check underscored variant of colorspace name # since we are reformatting it in integrate.py - if colorspace_key.replace(" ", "_") in path: + if colorspace_key.replace(" ", "_") in filepath: colorspace_name = colorspace_key break - if colorspace_key in path: + if colorspace_key in filepath: colorspace_name = colorspace_key break if not colorspace_name: log.info("No matching colorspace in config '{}' for path: '{}'".format( - config_path, path + config_path, filepath )) return None diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index e63ca510f2..8ae98f7cf8 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -132,14 +132,14 @@ class TestPipelineColorspace(TestPipeline): path_1 = "renderCompMain_ACES2065-1.####.exr" expected_1 = "ACES2065-1" ret_1 = colorspace.parse_colorspace_from_filepath( - path_1, "nuke", "test_project", project_settings=project_settings + path_1, config_path=config_path_asset ) assert ret_1 == expected_1, f"Not matching colorspace {expected_1}" path_2 = "renderCompMain_BMDFilm_WideGamut_Gen5.mov" expected_2 = "BMDFilm WideGamut Gen5" ret_2 = colorspace.parse_colorspace_from_filepath( - path_2, "nuke", "test_project", project_settings=project_settings + path_2, config_path=config_path_asset ) assert ret_2 == expected_2, f"Not matching colorspace {expected_2}" From c99aa746a2d63b092593fd24f0cda9caa397cd50 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 14:01:36 +0200 Subject: [PATCH 0047/1224] docstring update --- openpype/pipeline/colorspace.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 9ea9d1f888..e675bdb2e1 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -226,13 +226,11 @@ def get_colorspace_from_filepath(config_path, filepath): def parse_colorspace_from_filepath( filepath, colorspaces=None, config_path=None ): - """Parse colorspace name from list of filepaths + """Parse colorspace name from filepath An input path can have colorspace name used as part of name or as folder name. - # add example python code block - Example: >>> config_path = "path/to/config.ocio" >>> colorspaces = get_ocio_config_colorspaces(config_path) From ba237487a65176f2ee58e08533c65115b5a9c19d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 15:53:36 +0200 Subject: [PATCH 0048/1224] cashing OCIO config version data --- openpype/pipeline/colorspace.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index e675bdb2e1..30bd685b13 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -23,6 +23,7 @@ log = Logger.get_logger(__name__) class CashedData: remapping = None python3compatible = None + config_version_data = None class DeprecatedWarning(DeprecationWarning): @@ -395,16 +396,17 @@ def compatibility_check_config_version(config_path, major=1, minor=None): "config", "get_version", config_path=config_path ) - from openpype.scripts.ocio_wrapper import _get_version_data + if not CashedData.config_version_data: + from openpype.scripts.ocio_wrapper import _get_version_data - version_data = _get_version_data(config_path) + CashedData.config_version_data = _get_version_data(config_path) # check major version - if version_data["major"] != major: + if CashedData.config_version_data["major"] != major: return False # check minor version - if minor and version_data["minor"] != minor: + if minor and CashedData.config_version_data["minor"] != minor: return False # compatible From 06930318c2762b0f80d60ef07dd3d38a79e7b318 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 16:01:25 +0200 Subject: [PATCH 0049/1224] fixing logic of the cashing --- openpype/pipeline/colorspace.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 30bd685b13..c1dc47245a 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -389,17 +389,19 @@ def compatibility_check(): def compatibility_check_config_version(config_path, major=1, minor=None): """Making sure PyOpenColorIO config version is compatible""" - if not compatibility_check(): - # python environment is not compatible with PyOpenColorIO - # needs to be run in subprocess - version_data = get_wrapped_with_subprocess( - "config", "get_version", config_path=config_path - ) - if not CashedData.config_version_data: - from openpype.scripts.ocio_wrapper import _get_version_data + if compatibility_check(): + from openpype.scripts.ocio_wrapper import _get_version_data + + CashedData.config_version_data = _get_version_data(config_path) + + else: + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CashedData.config_version_data = get_wrapped_with_subprocess( + "config", "get_version", config_path=config_path + ) - CashedData.config_version_data = _get_version_data(config_path) # check major version if CashedData.config_version_data["major"] != major: From 6a36d713139ccd4e21b44c0c247a6adda236c863 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 16:26:36 +0200 Subject: [PATCH 0050/1224] adding colorspace parsing form filepath as fallback --- openpype/pipeline/colorspace.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index c1dc47245a..eb3c4c3b94 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -170,8 +170,6 @@ def get_imageio_colorspace_from_filepath( # if no file rule matched, try to get colorspace # from filepath with OCIO v2 way - # QUESTION: should we override file rules from our settings and - # in ocio v2 only focus on file rules set in config file? if ( compatibility_check_config_version(config_data["path"], major=2) and not colorspace_name @@ -179,6 +177,11 @@ def get_imageio_colorspace_from_filepath( colorspace_name = get_colorspace_from_filepath( config_data["path"], path) + # use parse colorspace from filepath as fallback + colorspace_name = colorspace_name or parse_colorspace_from_filepath( + path, config_path=config_data["path"] + ) + if not colorspace_name: log.info("No imageio file rule matched input path: '{}'".format( path @@ -196,7 +199,8 @@ def get_imageio_colorspace_from_filepath( def get_colorspace_from_filepath(config_path, filepath): """Get colorspace from file path wrapper. - Wrapper function for getting colorspace from file path. + Wrapper function for getting colorspace from file path + with use of OCIO v2 file-rules. Args: config_path (str): path leading to config.ocio file From 7bb23b1bc0d7af7e6260d135a1ef9c77c0f35810 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 16:35:03 +0200 Subject: [PATCH 0051/1224] utilising Cache for config colorspaces --- openpype/pipeline/colorspace.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index eb3c4c3b94..70b82d6be2 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -24,6 +24,7 @@ class CashedData: remapping = None python3compatible = None config_version_data = None + ocio_config_colorspaces = {} class DeprecatedWarning(DeprecationWarning): @@ -155,7 +156,7 @@ def get_imageio_colorspace_from_filepath( # match file rule from path colorspace_name = None - for _frule_name, file_rule in file_rules.items(): + for _, file_rule in file_rules.items(): pattern = file_rule["pattern"] extension = file_rule["ext"] ext_match = re.match( @@ -431,16 +432,22 @@ def get_ocio_config_colorspaces(config_path): Returns: dict: colorspace and family in couple """ - if not compatibility_check(): - # python environment is not compatible with PyOpenColorIO - # needs to be run in subprocess - return get_wrapped_with_subprocess( - "config", "get_colorspace", in_path=config_path - ) + if not CashedData.ocio_config_colorspaces.get(config_path): + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CashedData.ocio_config_colorspaces[config_path] = \ + get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) + else: + from openpype.scripts.ocio_wrapper import _get_colorspace_data - from openpype.scripts.ocio_wrapper import _get_colorspace_data + CashedData.ocio_config_colorspaces[config_path] = \ + _get_colorspace_data(config_path) + + return CashedData.ocio_config_colorspaces[config_path] - return _get_colorspace_data(config_path) # TODO: remove this in future - backward compatibility From ab872146a9bc52068dd4abb6c009160e1a79120c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 16:36:01 +0200 Subject: [PATCH 0052/1224] removing line --- openpype/pipeline/colorspace.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 70b82d6be2..8378da5b98 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -449,7 +449,6 @@ def get_ocio_config_colorspaces(config_path): return CashedData.ocio_config_colorspaces[config_path] - # TODO: remove this in future - backward compatibility @deprecated("get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): From b60043e94589b0a1dfdb7853deb6560b5255827b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 16:38:34 +0200 Subject: [PATCH 0053/1224] hound --- openpype/pipeline/colorspace.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 8378da5b98..1251308eb3 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -407,7 +407,6 @@ def compatibility_check_config_version(config_path, major=1, minor=None): "config", "get_version", config_path=config_path ) - # check major version if CashedData.config_version_data["major"] != major: return False From 20df677e48d52b00834348e813017f99d9c2a07d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 29 Aug 2023 22:39:49 +0800 Subject: [PATCH 0054/1224] 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 0055/1224] 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 fb4567560ec9973aab354c566bd1e00674190e5b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 14:53:04 +0200 Subject: [PATCH 0056/1224] colorspace: aggregating typed config context data --- openpype/scripts/ocio_wrapper.py | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 16558642c6..e8a503e42e 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -96,11 +96,41 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - return { - c.getName(): c.getFamily() - for c in config.getColorSpaces() + colorspace_data = { + color.getName(): { + "type": "colorspace", + "family": color.getFamily(), + "categories": list(color.getCategories()), + "aliases": list(color.getAliases()), + "equalitygroup": color.getEqualityGroup(), + } + for color in config.getColorSpaces() } + # add looks + looks = config.getLooks() + if looks: + colorspace_data.update({ + look.getName(): { + "type": "look", + "process_space": look.getProcessSpace() + } + for look in looks + }) + + # add roles + roles = config.getRoles() + if roles: + colorspace_data.update({ + role[0]: { + "type": "role", + "colorspace": role[1] + } + for role in roles + }) + + return colorspace_data + @config.command( name="get_views", From 3cc8c51ea2cb4bf9655bf3ae9cd5f53befb84b95 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 14:53:58 +0200 Subject: [PATCH 0057/1224] traypublish: adding colorspace look product type publishing workflow --- .../plugins/create/create_colorspace_look.py | 185 ++++++++++++++++++ .../publish/collect_colorspace_look.py | 46 +++++ .../publish/extract_colorspace_look.py | 43 ++++ .../publish/validate_colorspace_look.py | 70 +++++++ openpype/plugins/publish/integrate.py | 1 + 5 files changed, 345 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py new file mode 100644 index 0000000000..62ecc391f6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Creator of colorspace look files. + +This creator is used to publish colorspace look files thanks to +production type `ociolook`. All files are published as representation. +""" +from pathlib import Path + +from openpype.client import get_asset_by_name +from openpype.lib.attribute_definitions import ( + FileDef, EnumDef, TextDef, UISeparatorDef +) +from openpype.pipeline import ( + CreatedInstance, + CreatorError +) +from openpype.pipeline.create import ( + get_subset_name, + TaskNotSetError, +) +from openpype.pipeline import colorspace +from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator + + +class CreateColorspaceLook(TrayPublishCreator): + """Creates colorspace look files.""" + + identifier = "io.openpype.creators.traypublisher.colorspace_look" + label = "Colorspace Look" + family = "ociolook" + description = "Publishes color space look file." + extensions = [".cc", ".cube", ".3dl", ".spi1d", ".spi3d", ".csp", ".lut"] + enabled = False + + colorspace_items = [ + (None, "Not set") + ] + colorspace_attr_show = False + + def get_detail_description(self): + return """# Colorspace Look + +This creator publishes color space look file (LUT). + """ + + def get_icon(self): + return "mdi.format-color-fill" + + def create(self, subset_name, instance_data, pre_create_data): + repr_file = pre_create_data.get("luts_file") + if not repr_file: + raise CreatorError("No files specified") + + files = repr_file.get("filenames") + if not files: + # this should never happen + raise CreatorError("Missing files from representation") + + asset_doc = get_asset_by_name( + self.project_name, instance_data["asset"]) + + subset_name = self._get_subset( + asset_doc, instance_data["variant"], self.project_name, + instance_data["task"] + ) + + instance_data["creator_attributes"] = { + "abs_lut_path": ( + Path(repr_file["directory"]) / files[0]).as_posix() + } + + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, + instance_data, self) + self._store_new_instance(new_instance) + + def get_instance_attr_defs(self): + return [ + EnumDef( + "working_colorspace", + self.colorspace_items, + default="Not set", + label="Working Colorspace", + ), + UISeparatorDef( + label="Advanced1" + ), + TextDef( + "abs_lut_path", + label="LUT Path", + ), + EnumDef( + "input_colorspace", + self.colorspace_items, + default="Not set", + label="Input Colorspace", + ), + EnumDef( + "direction", + [ + (None, "Not set"), + ("forward", "Forward"), + ("inverse", "Inverse") + ], + default="Not set", + label="Direction" + ), + EnumDef( + "interpolation", + [ + (None, "Not set"), + ("linear", "Linear"), + ("tetrahedral", "Tetrahedral"), + ("best", "Best"), + ("nearest", "Nearest") + ], + default="Not set", + label="Interpolation" + ), + EnumDef( + "output_colorspace", + self.colorspace_items, + default="Not set", + label="Output Colorspace", + ), + ] + + def get_pre_create_attr_defs(self): + return [ + FileDef( + "luts_file", + folders=False, + extensions=self.extensions, + allow_sequences=False, + single_item=True, + label="Look Files", + ) + ] + + def apply_settings(self, project_settings, system_settings): + host = self.create_context.host + host_name = host.name + project_name = host.get_current_project_name() + config_data = colorspace.get_imageio_config( + project_name, host_name, + project_settings=project_settings + ) + + if config_data: + filepath = config_data["path"] + config_items = colorspace.get_ocio_config_colorspaces(filepath) + + self.colorspace_items.extend(( + (name, f"{name} [{data_['type']}]") + for name, data_ in config_items.items() + if data_.get("type") == "colorspace" + )) + self.enabled = True + + def _get_subset(self, asset_doc, variant, project_name, task_name=None): + """Create subset name according to standard template process""" + + try: + subset_name = get_subset_name( + self.family, + variant, + task_name, + asset_doc, + project_name + ) + except TaskNotSetError: + # Create instance with fake task + # - instance will be marked as invalid so it can't be published + # but user have ability to change it + # NOTE: This expect that there is not task 'Undefined' on asset + task_name = "Undefined" + subset_name = get_subset_name( + self.family, + variant, + task_name, + asset_doc, + project_name + ) + + return subset_name diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py new file mode 100644 index 0000000000..739ab33f9c --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -0,0 +1,46 @@ +import os +import pyblish.api +from openpype.pipeline import publish + + +class CollectColorspaceLook(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin): + """Collect OCIO colorspace look from LUT file + """ + + label = "Collect Colorspace Look" + order = pyblish.api.CollectorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + creator_attrs = instance.data["creator_attributes"] + + lut_repre_name = "LUTfile" + file_url = creator_attrs["abs_lut_path"] + file_name = os.path.basename(file_url) + _, ext = os.path.splitext(file_name) + + # create lut representation data + lut_repre = { + "name": lut_repre_name, + "ext": ext.lstrip("."), + "files": file_name, + "stagingDir": os.path.dirname(file_url), + "tags": [] + } + instance.data.update({ + "representations": [lut_repre], + "source": file_url, + "ocioLookItems": [ + { + "name": lut_repre_name, + "ext": ext.lstrip("."), + "working_colorspace": creator_attrs["working_colorspace"], + "input_colorspace": creator_attrs["input_colorspace"], + "output_colorspace": creator_attrs["output_colorspace"], + "direction": creator_attrs["direction"], + "interpolation": creator_attrs["interpolation"] + } + ] + }) diff --git a/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py new file mode 100644 index 0000000000..ffd877af1d --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py @@ -0,0 +1,43 @@ +import os +import json +import pyblish.api +from openpype.pipeline import publish + + +class ExtractColorspaceLook(publish.Extractor, + publish.OpenPypePyblishPluginMixin): + """Extract OCIO colorspace look from LUT file + """ + + label = "Extract Colorspace Look" + order = pyblish.api.ExtractorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + ociolook_items = instance.data["ocioLookItems"] + staging_dir = self.staging_dir(instance) + + # create ociolook file attributes + ociolook_file_name = "ocioLookFile.json" + ociolook_file_content = { + "version": 1, + "data": { + "ocioLookItems": ociolook_items + } + } + + # write ociolook content into json file saved in staging dir + file_url = os.path.join(staging_dir, ociolook_file_name) + with open(file_url, "w") as f_: + json.dump(ociolook_file_content, f_, indent=4) + + # create lut representation data + ociolook_repre = { + "name": "ocioLookFile", + "ext": "json", + "files": ociolook_file_name, + "stagingDir": staging_dir, + "tags": [] + } + instance.data["representations"].append(ociolook_repre) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py new file mode 100644 index 0000000000..7de8881321 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py @@ -0,0 +1,70 @@ +import pyblish.api + +from openpype.pipeline import ( + publish, + PublishValidationError +) + + +class ValidateColorspaceLook(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin): + """Validate colorspace look attributes""" + + label = "Validate colorspace look attributes" + order = pyblish.api.ValidatorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + create_context = instance.context.data["create_context"] + created_instance = create_context.get_instance_by_id( + instance.data["instance_id"]) + creator_defs = created_instance.creator_attribute_defs + + ociolook_items = instance.data.get("ocioLookItems", []) + + for ociolook_item in ociolook_items: + self.validate_colorspace_set_attrs(ociolook_item, creator_defs) + + def validate_colorspace_set_attrs(self, ociolook_item, creator_defs): + """Validate colorspace look attributes""" + + self.log.debug(f"Validate colorspace look attributes: {ociolook_item}") + self.log.debug(f"Creator defs: {creator_defs}") + + check_keys = [ + "working_colorspace", + "input_colorspace", + "output_colorspace", + "direction", + "interpolation" + ] + not_set_keys = [] + for key in check_keys: + if ociolook_item[key]: + # key is set and it is correct + continue + + def_label = next( + (d_.label for d_ in creator_defs if key == d_.key), + None + ) + if not def_label: + def_attrs = [(d_.key, d_.label) for d_ in creator_defs] + # raise since key is not recognized by creator defs + raise KeyError( + f"Colorspace look attribute '{key}' is not " + f"recognized by creator attributes: {def_attrs}" + ) + not_set_keys.append(def_label) + + if not_set_keys: + message = ( + f"Colorspace look attributes are not set: " + f"{', '.join(not_set_keys)}" + ) + raise PublishValidationError( + title="Colorspace Look attributes", + message=message, + description=message + ) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index be07cffe72..fe4bfc81f6 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -107,6 +107,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "rig", "plate", "look", + "ociolook", "audio", "yetiRig", "yeticache", From 7727a017da8e0a27debae5dd5c0a3140adab3803 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 14:57:22 +0200 Subject: [PATCH 0058/1224] filtering families into explicit colorspace --- .../traypublisher/plugins/publish/collect_explicit_colorspace.py | 1 + .../hosts/traypublisher/plugins/publish/validate_colorspace.py | 1 + 2 files changed, 2 insertions(+) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index eb7fbd87a0..860c36ccf8 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -13,6 +13,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, label = "Choose representation colorspace" order = pyblish.api.CollectorOrder + 0.49 hosts = ["traypublisher"] + families = ["render", "plate", "reference", "image", "online"] colorspace_items = [ (None, "Don't override") diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py index 75b41cf606..03f9f299b2 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py @@ -18,6 +18,7 @@ class ValidateColorspace(pyblish.api.InstancePlugin, label = "Validate representation colorspace" order = pyblish.api.ValidatorOrder hosts = ["traypublisher"] + families = ["render", "plate", "reference", "image", "online"] def process(self, instance): From 74ca6a3c44071035e9c3ef5a1ae22cd077647cee Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 14:58:15 +0200 Subject: [PATCH 0059/1224] explicit colorspace includes types and aliases to offered colorspace --- .../publish/collect_explicit_colorspace.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index 860c36ccf8..be8cf20e22 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -1,5 +1,4 @@ import pyblish.api -from openpype.pipeline import registered_host from openpype.pipeline import publish from openpype.lib import EnumDef from openpype.pipeline import colorspace @@ -38,7 +37,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, @classmethod def apply_settings(cls, project_settings): - host = registered_host() + host = self.create_context.host host_name = host.name project_name = host.get_current_project_name() config_data = colorspace.get_imageio_config( @@ -49,9 +48,28 @@ class CollectColorspace(pyblish.api.InstancePlugin, if config_data: filepath = config_data["path"] config_items = colorspace.get_ocio_config_colorspaces(filepath) + aliases = set() + for _, value_ in config_items.items(): + if value_.get("type") != "colorspace": + continue + if not value_.get("aliases"): + continue + for alias in value_.get("aliases"): + aliases.add(alias) + + colorspaces = { + name + for name, data_ in config_items.items() + if name not in aliases and data_.get("type") == "colorspace" + } + cls.colorspace_items.extend(( - (name, name) for name in config_items.keys() + (name, name) for name in colorspaces )) + if aliases: + cls.colorspace_items.extend(( + (name, name) for name in aliases + )) cls.colorspace_attr_show = True @classmethod From 42033a02946ee2e3480365613eef39ad1b7f3881 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 30 Aug 2023 21:09:12 +0800 Subject: [PATCH 0060/1224] 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 0061/1224] 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 a10206c05493bee85ca2c2bf92818bbb1fb3bdc1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 15:24:56 +0200 Subject: [PATCH 0062/1224] explicit colorspace switch and item labeling --- .../publish/collect_explicit_colorspace.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index be8cf20e22..08479b8363 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -1,5 +1,8 @@ import pyblish.api -from openpype.pipeline import publish +from openpype.pipeline import ( + publish, + registered_host +) from openpype.lib import EnumDef from openpype.pipeline import colorspace @@ -13,6 +16,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, order = pyblish.api.CollectorOrder + 0.49 hosts = ["traypublisher"] families = ["render", "plate", "reference", "image", "online"] + enabled = False colorspace_items = [ (None, "Don't override") @@ -37,7 +41,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, @classmethod def apply_settings(cls, project_settings): - host = self.create_context.host + host = registered_host() host_name = host.name project_name = host.get_current_project_name() config_data = colorspace.get_imageio_config( @@ -46,6 +50,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, ) if config_data: + filepath = config_data["path"] config_items = colorspace.get_ocio_config_colorspaces(filepath) aliases = set() @@ -58,19 +63,18 @@ class CollectColorspace(pyblish.api.InstancePlugin, aliases.add(alias) colorspaces = { - name - for name, data_ in config_items.items() - if name not in aliases and data_.get("type") == "colorspace" + name for name, data_ in config_items.items() + if data_.get("type") == "colorspace" } cls.colorspace_items.extend(( - (name, name) for name in colorspaces + (name, f"{name} [colorspace]") for name in colorspaces )) if aliases: cls.colorspace_items.extend(( - (name, name) for name in aliases + (name, f"{name} [alias]") for name in aliases )) - cls.colorspace_attr_show = True + cls.enabled = True @classmethod def get_attribute_defs(cls): @@ -79,7 +83,6 @@ class CollectColorspace(pyblish.api.InstancePlugin, "colorspace", cls.colorspace_items, default="Don't override", - label="Override Colorspace", - hidden=not cls.colorspace_attr_show + label="Override Colorspace" ) ] From a4e876792a28709a398433a7e047e40c865c4244 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 15:42:05 +0200 Subject: [PATCH 0063/1224] typo fix --- openpype/pipeline/colorspace.py | 46 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 1251308eb3..8025022c59 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -20,7 +20,7 @@ from openpype.pipeline import Anatomy log = Logger.get_logger(__name__) -class CashedData: +class CachedData: remapping = None python3compatible = None config_version_data = None @@ -378,41 +378,41 @@ def get_wrapped_with_subprocess(command_group, command, **kwargs): def compatibility_check(): """Making sure PyOpenColorIO is importable""" - if CashedData.python3compatible is not None: - return CashedData.python3compatible + if CachedData.python3compatible is not None: + return CachedData.python3compatible try: import PyOpenColorIO # noqa: F401 - CashedData.python3compatible = True + CachedData.python3compatible = True except ImportError: - CashedData.python3compatible = False + CachedData.python3compatible = False # compatible - return CashedData.python3compatible + return CachedData.python3compatible def compatibility_check_config_version(config_path, major=1, minor=None): """Making sure PyOpenColorIO config version is compatible""" - if not CashedData.config_version_data: + if not CachedData.config_version_data: if compatibility_check(): from openpype.scripts.ocio_wrapper import _get_version_data - CashedData.config_version_data = _get_version_data(config_path) + CachedData.config_version_data = _get_version_data(config_path) else: # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - CashedData.config_version_data = get_wrapped_with_subprocess( + CachedData.config_version_data = get_wrapped_with_subprocess( "config", "get_version", config_path=config_path ) # check major version - if CashedData.config_version_data["major"] != major: + if CachedData.config_version_data["major"] != major: return False # check minor version - if minor and CashedData.config_version_data["minor"] != minor: + if minor and CachedData.config_version_data["minor"] != minor: return False # compatible @@ -431,21 +431,21 @@ def get_ocio_config_colorspaces(config_path): Returns: dict: colorspace and family in couple """ - if not CashedData.ocio_config_colorspaces.get(config_path): + if not CachedData.ocio_config_colorspaces.get(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - CashedData.ocio_config_colorspaces[config_path] = \ + CachedData.ocio_config_colorspaces[config_path] = \ get_wrapped_with_subprocess( "config", "get_colorspace", in_path=config_path ) else: from openpype.scripts.ocio_wrapper import _get_colorspace_data - CashedData.ocio_config_colorspaces[config_path] = \ + CachedData.ocio_config_colorspaces[config_path] = \ _get_colorspace_data(config_path) - return CashedData.ocio_config_colorspaces[config_path] + return CachedData.ocio_config_colorspaces[config_path] # TODO: remove this in future - backward compatibility @@ -730,15 +730,15 @@ def get_remapped_colorspace_to_native( Union[str, None]: native colorspace name defined in remapping or None """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("to_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("to_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["to_native"] = { + CachedData.remapping[host_name]["to_native"] = { rule["ocio_name"]: rule["host_native_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["to_native"].get( + return CachedData.remapping[host_name]["to_native"].get( ocio_colorspace_name) @@ -756,15 +756,15 @@ def get_remapped_colorspace_from_native( Union[str, None]: Ocio colorspace name defined in remapping or None. """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("from_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("from_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["from_native"] = { + CachedData.remapping[host_name]["from_native"] = { rule["host_native_name"]: rule["ocio_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["from_native"].get( + return CachedData.remapping[host_name]["from_native"].get( host_native_colorspace_name) From 3292114e2109656e3fb92d6443db52838007181c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 15:46:59 +0200 Subject: [PATCH 0064/1224] better error message --- openpype/pipeline/colorspace.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 8025022c59..9c77723d12 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -257,8 +257,7 @@ def parse_colorspace_from_filepath( """ if not colorspaces and not config_path: raise ValueError( - "You need to provide `config_path` if you don't " - "want to provide input `colorspaces`." + "Must provide `config_path` if `colorspaces` is not provided." ) colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) From c89d384e5a0033d436763751d3be9c85ea639ce2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 16:55:36 +0200 Subject: [PATCH 0065/1224] optimisation of regex search suggestion from https://github.com/ynput/OpenPype/pull/5273#discussion_r1309372596 --- openpype/pipeline/colorspace.py | 49 +++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 9c77723d12..47227a0e3b 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -255,24 +255,49 @@ def parse_colorspace_from_filepath( Returns: str: name of colorspace """ + def _get_colorspace_match_regex(colorspaces): + """Return a regex patter + + Allows to search a colorspace match in a filename + + Args: + colorspaces (list): List of colorspace names + + Returns: + re.Pattern: regex pattern + """ + pattern = "|".join( + # Allow to match spaces also as underscores because the + # integrator replaces spaces with underscores in filenames + re.escape(colorspace).replace(r"\ ", r"[_ ]") for colorspace in + # Sort by longest first so the regex matches longer matches + # over smaller matches, e.g. matching 'Output - sRGB' over 'sRGB' + sorted(colorspaces, key=len, reverse=True) + ) + return re.compile(pattern) + if not colorspaces and not config_path: raise ValueError( "Must provide `config_path` if `colorspaces` is not provided." ) - colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) - - # match file rule from path colorspace_name = None - for colorspace_key in colorspaces: - # check underscored variant of colorspace name - # since we are reformatting it in integrate.py - if colorspace_key.replace(" ", "_") in filepath: - colorspace_name = colorspace_key - break - if colorspace_key in filepath: - colorspace_name = colorspace_key - break + colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) + underscored_colorspaces = { + key.replace(" ", "_"): key for key in colorspaces + if " " in key + } + + # match colorspace from filepath + regex_pattern = _get_colorspace_match_regex(colorspaces) + match = regex_pattern.search(filepath) + colorspace = match.group(0) if match else None + + if colorspace: + colorspace_name = colorspace + + if colorspace in underscored_colorspaces: + colorspace_name = underscored_colorspaces[colorspace] if not colorspace_name: log.info("No matching colorspace in config '{}' for path: '{}'".format( From 3928e26c45f91b1d2113268c8d0c4a889db93cbf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 17:02:59 +0200 Subject: [PATCH 0066/1224] turn public to non-public function --- openpype/pipeline/colorspace.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 47227a0e3b..118466bc92 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -213,7 +213,7 @@ def get_colorspace_from_filepath(config_path, filepath): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - result_data = get_wrapped_with_subprocess( + result_data = _get_wrapped_with_subprocess( "colorspace", "get_colorspace_from_filepath", config_path=config_path, filepath=filepath @@ -331,7 +331,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): # TODO: remove this in future - backward compatibility -@deprecated("get_wrapped_with_subprocess") +@deprecated("_get_wrapped_with_subprocess") def get_data_subprocess(config_path, data_type): """[Deprecated] Get data via subprocess @@ -361,7 +361,7 @@ def get_data_subprocess(config_path, data_type): return json.loads(return_json_data) -def get_wrapped_with_subprocess(command_group, command, **kwargs): +def _get_wrapped_with_subprocess(command_group, command, **kwargs): """Get data via subprocess Wrapper for Python 2 hosts. @@ -427,7 +427,7 @@ def compatibility_check_config_version(config_path, major=1, minor=None): else: # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - CachedData.config_version_data = get_wrapped_with_subprocess( + CachedData.config_version_data = _get_wrapped_with_subprocess( "config", "get_version", config_path=config_path ) @@ -460,7 +460,7 @@ def get_ocio_config_colorspaces(config_path): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess CachedData.ocio_config_colorspaces[config_path] = \ - get_wrapped_with_subprocess( + _get_wrapped_with_subprocess( "config", "get_colorspace", in_path=config_path ) else: @@ -473,7 +473,7 @@ def get_ocio_config_colorspaces(config_path): # TODO: remove this in future - backward compatibility -@deprecated("get_wrapped_with_subprocess") +@deprecated("_get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): """[Deprecated] Get colorspace data via subprocess @@ -485,7 +485,7 @@ def get_colorspace_data_subprocess(config_path): Returns: dict: colorspace and family in couple """ - return get_wrapped_with_subprocess( + return _get_wrapped_with_subprocess( "config", "get_colorspace", in_path=config_path ) @@ -505,7 +505,7 @@ def get_ocio_config_views(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - return get_wrapped_with_subprocess( + return _get_wrapped_with_subprocess( "config", "get_views", in_path=config_path ) @@ -515,7 +515,7 @@ def get_ocio_config_views(config_path): # TODO: remove this in future - backward compatibility -@deprecated("get_wrapped_with_subprocess") +@deprecated("_get_wrapped_with_subprocess") def get_views_data_subprocess(config_path): """[Deprecated] Get viewers data via subprocess @@ -527,7 +527,7 @@ def get_views_data_subprocess(config_path): Returns: dict: `display/viewer` and viewer data """ - return get_wrapped_with_subprocess( + return _get_wrapped_with_subprocess( "config", "get_views", in_path=config_path ) From 7db4be1a482bbe63e24f1a0176ed35e260cc7bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 30 Aug 2023 17:04:24 +0200 Subject: [PATCH 0067/1224] Update openpype/pipeline/colorspace.py Co-authored-by: Roy Nieterau --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 118466bc92..fa9f124130 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -156,7 +156,7 @@ def get_imageio_colorspace_from_filepath( # match file rule from path colorspace_name = None - for _, file_rule in file_rules.items(): + for file_rule in file_rules.values(): pattern = file_rule["pattern"] extension = file_rule["ext"] ext_match = re.match( From d570a2bff19fdaa6f0af909c5e450275684f1ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 30 Aug 2023 17:05:48 +0200 Subject: [PATCH 0068/1224] Update openpype/pipeline/colorspace.py Co-authored-by: Roy Nieterau --- openpype/pipeline/colorspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index fa9f124130..20ca60b10e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -357,8 +357,8 @@ def get_data_subprocess(config_path, data_type): run_openpype_process(*args, **process_kwargs) # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + with open(tmp_json_path, "r") as f: + return json.load(f) def _get_wrapped_with_subprocess(command_group, command, **kwargs): From e8c58f13d7a1b394aae3ad60a7ec2bc290b38511 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 17:08:42 +0200 Subject: [PATCH 0069/1224] suggestion for json return from file --- openpype/pipeline/colorspace.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 20ca60b10e..c7eb778fa2 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -357,8 +357,8 @@ def get_data_subprocess(config_path, data_type): run_openpype_process(*args, **process_kwargs) # return all colorspaces - with open(tmp_json_path, "r") as f: - return json.load(f) + with open(tmp_json_path, "r") as f_: + return json.load(f_) def _get_wrapped_with_subprocess(command_group, command, **kwargs): @@ -396,8 +396,8 @@ def _get_wrapped_with_subprocess(command_group, command, **kwargs): run_openpype_process(*args, **process_kwargs) # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + with open(tmp_json_path, "r") as f_: + return json.load(f_) def compatibility_check(): From f1cf4fcda6a205f8c89573f3f5892acb8c5d51e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 17:23:01 +0200 Subject: [PATCH 0070/1224] removing deprecated code with backward compatibility --- openpype/pipeline/colorspace.py | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index c7eb778fa2..9f1c297188 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -340,25 +340,9 @@ def get_data_subprocess(config_path, data_type): Args: config_path (str): path leading to config.ocio file """ - with _make_temp_json_file() as tmp_json_path: - # Prepare subprocess arguments - args = [ - "run", get_ocio_config_script_path(), - "config", data_type, - "--in_path", config_path, - "--out_path", tmp_json_path - ] - log.info("Executing: {}".format(" ".join(args))) - - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) - - # return all colorspaces - with open(tmp_json_path, "r") as f_: - return json.load(f_) + return _get_wrapped_with_subprocess( + "config", data_type, in_path=config_path, + ) def _get_wrapped_with_subprocess(command_group, command, **kwargs): From a6b8fdfe33dc5dcf1eb1120a65f5ad264b3b1ef4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Aug 2023 22:43:06 +0200 Subject: [PATCH 0071/1224] refactor function names and code logic for colorspace from filepath --- openpype/pipeline/colorspace.py | 118 +++++++++++++++++++++---------- openpype/scripts/ocio_wrapper.py | 10 +-- 2 files changed, 84 insertions(+), 44 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 9f1c297188..f1acd18a70 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -115,27 +115,92 @@ def get_ocio_config_script_path(): ) -def get_imageio_colorspace_from_filepath( - path, host_name, project_name, +def get_colorspace_name_from_filepath( + filepath, host_name, project_name, config_data=None, file_rules=None, project_settings=None, validate=True ): """Get colorspace name from filepath + Args: + filepath (str): path string, file rule pattern is tested on it + host_name (str): host name + project_name (str): project name + config_data (Optional[dict]): config path and template in dict. + Defaults to None. + file_rules (Optional[dict]): file rule data from settings. + Defaults to None. + project_settings (Optional[dict]): project settings. Defaults to None. + validate (Optional[bool]): should resulting colorspace be validated + with config file? Defaults to True. + + Returns: + str: name of colorspace + """ + # use ImageIO file rules + colorspace_name = get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) + + # try to get colorspace from OCIO v2 file rules + if ( + not colorspace_name + and compatibility_check_config_version(config_data["path"], major=2) + ): + colorspace_name = get_config_file_rules_colorspace_from_filepath( + config_data["path"], filepath) + + # use parse colorspace from filepath as fallback + colorspace_name = colorspace_name or parse_colorspace_from_filepath( + filepath, config_path=config_data["path"] + ) + + if not colorspace_name: + log.info("No imageio file rule matched input path: '{}'".format( + filepath + )) + return None + + # validate matching colorspace with config + if validate and config_data: + validate_imageio_colorspace_in_config( + config_data["path"], colorspace_name) + + return colorspace_name + + +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_imageio_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + + +def get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=None, file_rules=None, + project_settings=None +): + """Get colorspace name from filepath + ImageIO Settings file rules are tested for matching rule. Args: - path (str): path string, file rule pattern is tested on it + filepath (str): path string, file rule pattern is tested on it host_name (str): host name project_name (str): project name - config_data (dict, optional): config path and template in dict. + config_data (Optional[dict]): config path and template in dict. Defaults to None. - file_rules (dict, optional): file rule data from settings. + file_rules (Optional[dict]): file rule data from settings. Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. - validate (bool, optional): should resulting colorspace be validated - with config file? Defaults to True. + project_settings (Optional[dict]): project settings. Defaults to None. Returns: str: name of colorspace @@ -160,44 +225,19 @@ def get_imageio_colorspace_from_filepath( pattern = file_rule["pattern"] extension = file_rule["ext"] ext_match = re.match( - r".*(?=.{})".format(extension), path + r".*(?=.{})".format(extension), filepath ) file_match = re.search( - pattern, path + pattern, filepath ) if ext_match and file_match: colorspace_name = file_rule["colorspace"] - # if no file rule matched, try to get colorspace - # from filepath with OCIO v2 way - if ( - compatibility_check_config_version(config_data["path"], major=2) - and not colorspace_name - ): - colorspace_name = get_colorspace_from_filepath( - config_data["path"], path) - - # use parse colorspace from filepath as fallback - colorspace_name = colorspace_name or parse_colorspace_from_filepath( - path, config_path=config_data["path"] - ) - - if not colorspace_name: - log.info("No imageio file rule matched input path: '{}'".format( - path - )) - return None - - # validate matching colorspace with config - if validate and config_data: - validate_imageio_colorspace_in_config( - config_data["path"], colorspace_name) - return colorspace_name -def get_colorspace_from_filepath(config_path, filepath): +def get_config_file_rules_colorspace_from_filepath(config_path, filepath): """Get colorspace from file path wrapper. Wrapper function for getting colorspace from file path @@ -214,16 +254,16 @@ def get_colorspace_from_filepath(config_path, filepath): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess result_data = _get_wrapped_with_subprocess( - "colorspace", "get_colorspace_from_filepath", + "colorspace", "get_config_file_rules_colorspace_from_filepath", config_path=config_path, filepath=filepath ) if result_data: return result_data[0] - from openpype.scripts.ocio_wrapper import _get_colorspace_from_filepath + from openpype.scripts.ocio_wrapper import _get_config_file_rules_colorspace_from_filepath - result_data = _get_colorspace_from_filepath(config_path, filepath) + result_data = _get_config_file_rules_colorspace_from_filepath(config_path, filepath) if result_data: return result_data[0] diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 1feedde627..1515cb4e40 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -247,7 +247,7 @@ def _get_version_data(config_path): @colorspace.command( - name="get_colorspace_from_filepath", + name="get_config_file_rules_colorspace_from_filepath", help=( "return colorspace from filepath " "--config_path - ocio config file path (input arg is required) " @@ -264,7 +264,7 @@ def _get_version_data(config_path): @click.option("--out_path", required=True, help="path where to write output json file", type=click.Path()) -def get_colorspace_from_filepath(config_path, filepath, out_path): +def get_config_file_rules_colorspace_from_filepath(config_path, filepath, out_path): """Get colorspace from file path wrapper. Python 2 wrapped console command @@ -275,12 +275,12 @@ def get_colorspace_from_filepath(config_path, filepath, out_path): out_path (str): temp json file path string Example of use: - > pyton.exe ./ocio_wrapper.py colorspace get_colorspace_from_filepath \ + > pyton.exe ./ocio_wrapper.py colorspace get_config_file_rules_colorspace_from_filepath \ --config_path= --filepath= --out_path= """ json_path = Path(out_path) - colorspace = _get_colorspace_from_filepath(config_path, filepath) + colorspace = _get_config_file_rules_colorspace_from_filepath(config_path, filepath) with open(json_path, "w") as f_: json.dump(colorspace, f_) @@ -288,7 +288,7 @@ def get_colorspace_from_filepath(config_path, filepath, out_path): print(f"Colorspace name is saved to '{json_path}'") -def _get_colorspace_from_filepath(config_path, filepath): +def _get_config_file_rules_colorspace_from_filepath(config_path, filepath): """Return found colorspace data found in v2 file rules. Args: From f5d145ea23eb6b6463e1fea0ed07b4b1e0ffe934 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 10:42:57 +0200 Subject: [PATCH 0072/1224] hound and todos --- openpype/pipeline/colorspace.py | 11 +++++++++-- openpype/scripts/ocio_wrapper.py | 10 +++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index f1acd18a70..e315633d41 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -261,9 +261,11 @@ def get_config_file_rules_colorspace_from_filepath(config_path, filepath): if result_data: return result_data[0] - from openpype.scripts.ocio_wrapper import _get_config_file_rules_colorspace_from_filepath + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_config_file_rules_colorspace_from_filepath # noqa: E501 - result_data = _get_config_file_rules_colorspace_from_filepath(config_path, filepath) + result_data = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) if result_data: return result_data[0] @@ -424,6 +426,7 @@ def _get_wrapped_with_subprocess(command_group, command, **kwargs): return json.load(f_) +# TODO: this should be part of ocio_wrapper.py def compatibility_check(): """Making sure PyOpenColorIO is importable""" if CachedData.python3compatible is not None: @@ -439,11 +442,13 @@ def compatibility_check(): return CachedData.python3compatible +# TODO: this should be part of ocio_wrapper.py def compatibility_check_config_version(config_path, major=1, minor=None): """Making sure PyOpenColorIO config version is compatible""" if not CachedData.config_version_data: if compatibility_check(): + # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_version_data CachedData.config_version_data = _get_version_data(config_path) @@ -488,6 +493,7 @@ def get_ocio_config_colorspaces(config_path): "config", "get_colorspace", in_path=config_path ) else: + # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_colorspace_data CachedData.ocio_config_colorspaces[config_path] = \ @@ -533,6 +539,7 @@ def get_ocio_config_views(config_path): "config", "get_views", in_path=config_path ) + # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_views_data return _get_views_data(config_path) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 1515cb4e40..56399f10a2 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -264,7 +264,9 @@ def _get_version_data(config_path): @click.option("--out_path", required=True, help="path where to write output json file", type=click.Path()) -def get_config_file_rules_colorspace_from_filepath(config_path, filepath, out_path): +def get_config_file_rules_colorspace_from_filepath( + config_path, filepath, out_path +): """Get colorspace from file path wrapper. Python 2 wrapped console command @@ -275,12 +277,14 @@ def get_config_file_rules_colorspace_from_filepath(config_path, filepath, out_pa out_path (str): temp json file path string Example of use: - > pyton.exe ./ocio_wrapper.py colorspace get_config_file_rules_colorspace_from_filepath \ + > pyton.exe ./ocio_wrapper.py \ + colorspace get_config_file_rules_colorspace_from_filepath \ --config_path= --filepath= --out_path= """ json_path = Path(out_path) - colorspace = _get_config_file_rules_colorspace_from_filepath(config_path, filepath) + colorspace = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) with open(json_path, "w") as f_: json.dump(colorspace, f_) From abedafaff808a02088ef09ef748fa64e8604abaa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 10:56:56 +0200 Subject: [PATCH 0073/1224] fixing name of variable in tests --- tests/unit/openpype/pipeline/test_colorspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index 8ae98f7cf8..338627098c 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -227,7 +227,7 @@ class TestPipelineColorspace(TestPipeline): expected_hiero = "Gamma 2.2 Rec.709 - Texture" # switch to python 2 compatibility mode - colorspace.CashedData.python3compatible = False + colorspace.CachedData.python3compatible = False nuke_colorspace = colorspace.get_imageio_colorspace_from_filepath( nuke_filepath, @@ -248,7 +248,7 @@ class TestPipelineColorspace(TestPipeline): f"Not matching colorspace {expected_hiero}") # return to python 3 compatibility mode - colorspace.CashedData.python3compatible = None + colorspace.CachedData.python3compatible = None test_case = TestPipelineColorspace() From f7ce6406f94703dab8c47850c49dc6bd8c2d4920 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 11:46:05 +0200 Subject: [PATCH 0074/1224] adding contextual settings method to be shared between two functions --- openpype/pipeline/colorspace.py | 52 +++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index e315633d41..a4901b7dfd 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -138,6 +138,16 @@ def get_colorspace_name_from_filepath( Returns: str: name of colorspace """ + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) + + if not config_data: + # in case global or host color management is not enabled + return None + # use ImageIO file rules colorspace_name = get_imageio_file_rules_colorspace_from_filepath( filepath, host_name, project_name, @@ -183,6 +193,28 @@ def get_colorspace_from_filepath(*args, **kwargs): return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) +def _get_context_settings( + host_name, project_name, + config_data=None, file_rules=None, + project_settings=None +): + project_settings = project_settings or get_project_settings( + project_name + ) + + config_data = config_data or get_imageio_config( + project_name, host_name, project_settings) + + # in case host color management is not enabled + if not config_data: + return (None, None, None) + + file_rules = file_rules or get_imageio_file_rules( + project_name, host_name, project_settings) + + return project_settings, config_data, file_rules + + def get_imageio_file_rules_colorspace_from_filepath( filepath, host_name, project_name, config_data=None, file_rules=None, @@ -205,19 +237,15 @@ def get_imageio_file_rules_colorspace_from_filepath( Returns: str: name of colorspace """ - if not any([config_data, file_rules]): - project_settings = project_settings or get_project_settings( - project_name - ) - config_data = get_imageio_config( - project_name, host_name, project_settings) + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, project_settings) + if not config_data: + # in case global or host color management is not enabled + return None # match file rule from path colorspace_name = None From 1e6f855e7420a123539b37afa5cc5c580412c09c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 11:46:48 +0200 Subject: [PATCH 0075/1224] fixing tests --- tests/unit/openpype/pipeline/test_colorspace.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index 338627098c..435ea709ab 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -196,7 +196,7 @@ class TestPipelineColorspace(TestPipeline): expected_nuke = "Camera Rec.709" expected_hiero = "Gamma 2.2 Rec.709 - Texture" - nuke_colorspace = colorspace.get_imageio_colorspace_from_filepath( + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( nuke_filepath, "nuke", "test_project", @@ -205,7 +205,7 @@ class TestPipelineColorspace(TestPipeline): assert expected_nuke == nuke_colorspace, ( f"Not matching colorspace {expected_nuke}") - hiero_colorspace = colorspace.get_imageio_colorspace_from_filepath( + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( hiero_filepath, "hiero", "test_project", @@ -229,7 +229,7 @@ class TestPipelineColorspace(TestPipeline): # switch to python 2 compatibility mode colorspace.CachedData.python3compatible = False - nuke_colorspace = colorspace.get_imageio_colorspace_from_filepath( + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( nuke_filepath, "nuke", "test_project", @@ -238,7 +238,7 @@ class TestPipelineColorspace(TestPipeline): assert expected_nuke == nuke_colorspace, ( f"Not matching colorspace {expected_nuke}") - hiero_colorspace = colorspace.get_imageio_colorspace_from_filepath( + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( hiero_filepath, "hiero", "test_project", From fc3598ca2b3e249ae4dc52b606ce33cc2bc2dbab Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 23:27:03 +0800 Subject: [PATCH 0076/1224] 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 0077/1224] 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 0078/1224] 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 265dee372e8f4b99705fe7dbc5f6463241a433b6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 12:20:53 +0200 Subject: [PATCH 0079/1224] Blender: Change logs to `debug` in favor of new publisher artist-facing report Note that Blender is still using old publisher currently --- .../hosts/blender/plugins/publish/extract_abc.py | 2 +- .../blender/plugins/publish/extract_abc_animation.py | 2 +- .../hosts/blender/plugins/publish/extract_blend.py | 2 +- .../plugins/publish/extract_blend_animation.py | 2 +- .../blender/plugins/publish/extract_camera_abc.py | 2 +- .../blender/plugins/publish/extract_camera_fbx.py | 2 +- .../hosts/blender/plugins/publish/extract_fbx.py | 2 +- .../blender/plugins/publish/extract_fbx_animation.py | 2 +- .../hosts/blender/plugins/publish/extract_layout.py | 2 +- .../blender/plugins/publish/extract_playblast.py | 12 +++++------- .../blender/plugins/publish/extract_thumbnail.py | 6 +++--- 11 files changed, 17 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index f4babc94d3..f5b4c664a3 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -24,7 +24,7 @@ class ExtractABC(publish.Extractor): context = bpy.context # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index e141ccaa44..793e460390 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -23,7 +23,7 @@ class ExtractAnimationABC(publish.Extractor): context = bpy.context # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index d4f26b4f3c..c8eeef7fd7 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -21,7 +21,7 @@ class ExtractBlend(publish.Extractor): filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") data_blocks = set() diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 477411b73d..661cecce81 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -21,7 +21,7 @@ class ExtractBlendAnimation(publish.Extractor): filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") data_blocks = set() diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index a21a59b151..185259d987 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -24,7 +24,7 @@ class ExtractCameraABC(publish.Extractor): context = bpy.context # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py index 315994140e..a541f5b375 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py @@ -21,7 +21,7 @@ class ExtractCamera(publish.Extractor): filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index 0ad797c226..f2ce117dcd 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -22,7 +22,7 @@ class ExtractFBX(publish.Extractor): filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 062b42e99d..5fe5931e65 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -23,7 +23,7 @@ class ExtractAnimationFBX(publish.Extractor): stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") # The first collection object in the instance is taken, as there # should be only one that contains the asset group. diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index f2d04f1178..05f86b8370 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -117,7 +117,7 @@ class ExtractLayout(publish.Extractor): stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py index 196e75b8cc..b0099cce85 100644 --- a/openpype/hosts/blender/plugins/publish/extract_playblast.py +++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py @@ -24,9 +24,7 @@ class ExtractPlayblast(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.01 def process(self, instance): - self.log.info("Extracting capture..") - - self.log.info(instance.data) + self.log.debug("Extracting capture..") # get scene fps fps = instance.data.get("fps") @@ -34,14 +32,14 @@ class ExtractPlayblast(publish.Extractor): fps = bpy.context.scene.render.fps instance.data["fps"] = fps - self.log.info(f"fps: {fps}") + self.log.debug(f"fps: {fps}") # If start and end frames cannot be determined, # get them from Blender timeline. start = instance.data.get("frameStart", bpy.context.scene.frame_start) end = instance.data.get("frameEnd", bpy.context.scene.frame_end) - self.log.info(f"start: {start}, end: {end}") + self.log.debug(f"start: {start}, end: {end}") assert end > start, "Invalid time range !" # get cameras @@ -55,7 +53,7 @@ class ExtractPlayblast(publish.Extractor): filename = instance.name path = os.path.join(stagingdir, filename) - self.log.info(f"Outputting images to {path}") + self.log.debug(f"Outputting images to {path}") project_settings = instance.context.data["project_settings"]["blender"] presets = project_settings["publish"]["ExtractPlayblast"]["presets"] @@ -100,7 +98,7 @@ class ExtractPlayblast(publish.Extractor): frame_collection = collections[0] - self.log.info(f"We found collection of interest {frame_collection}") + self.log.debug(f"Found collection of interest {frame_collection}") instance.data.setdefault("representations", []) diff --git a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py index 65c3627375..52e5d98fc4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py @@ -24,13 +24,13 @@ class ExtractThumbnail(publish.Extractor): presets = {} def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") stagingdir = self.staging_dir(instance) filename = instance.name path = os.path.join(stagingdir, filename) - self.log.info(f"Outputting images to {path}") + self.log.debug(f"Outputting images to {path}") camera = instance.data.get("review_camera", "AUTO") start = instance.data.get("frameStart", bpy.context.scene.frame_start) @@ -61,7 +61,7 @@ class ExtractThumbnail(publish.Extractor): thumbnail = os.path.basename(self._fix_output_path(path)) - self.log.info(f"thumbnail: {thumbnail}") + self.log.debug(f"thumbnail: {thumbnail}") instance.data.setdefault("representations", []) From eadce2ce29f0df9da2a1b1c740e10bed4fa8a95e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 12:22:24 +0200 Subject: [PATCH 0080/1224] Fusion: Create Saver fix redeclaration of `default_variants` --- openpype/hosts/fusion/plugins/create/create_saver.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..27ff590cb3 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -30,10 +30,6 @@ class CreateSaver(NewCreator): instance_attributes = [ "reviewable" ] - default_variants = [ - "Main", - "Mask" - ] # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( From 5d221cf476000483a307fa212dd0d1b5ce0c618e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 12:23:17 +0200 Subject: [PATCH 0081/1224] Fusion: Fix saver being created in incorrect state without saving directly after create Without this fix creating a saver, then resetting the publisher will leave the saver in a broken imprinted state and will not be found by the publisher --- openpype/hosts/fusion/plugins/create/create_saver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 27ff590cb3..81dd235242 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -69,8 +69,6 @@ class CreateSaver(NewCreator): # TODO Is this needed? saver[file_format]["SaveAlpha"] = 1 - self._imprint(saver, instance_data) - # Register the CreatedInstance instance = CreatedInstance( family=self.family, @@ -78,6 +76,8 @@ class CreateSaver(NewCreator): data=instance_data, creator=self, ) + data = instance.data_to_store() + self._imprint(saver, data) # Insert the transient data instance.transient_data["tool"] = saver From 56147ca26e5f994e02c4568d75be8bfcaf9fe2cb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 12:23:41 +0200 Subject: [PATCH 0082/1224] Fusion: Allow reset frame range on `render` family --- openpype/hosts/fusion/plugins/load/actions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index f83ab433ee..94ba361b50 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -11,6 +11,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin): families = ["animation", "camera", "imagesequence", + "render", "yeticache", "pointcache", "render"] @@ -46,6 +47,7 @@ class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): families = ["animation", "camera", "imagesequence", + "render", "yeticache", "pointcache", "render"] From 2e6bdad7a0ede6c1445ef4e154df14cfbd355f40 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 12:24:13 +0200 Subject: [PATCH 0083/1224] Fusion: Tweak logging level for artist-facing report --- openpype/hosts/fusion/plugins/publish/collect_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index 6016baa2a9..4d6da79b77 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -85,5 +85,5 @@ class CollectInstanceData(pyblish.api.InstancePlugin): # Add review family if the instance is marked as 'review' # This could be done through a 'review' Creator attribute. if instance.data.get("review", False): - self.log.info("Adding review family..") + self.log.debug("Adding review family..") instance.data["families"].append("review") From 0cee44306e50e8f012ea559e477ae71770deb5f9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 23:02:02 +0800 Subject: [PATCH 0084/1224] 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 5eddd0f601b8cf61c83267bca031832af622b0c1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 17:21:44 +0200 Subject: [PATCH 0085/1224] Tweak code for readability --- .../publish/validator_saver_resolution.py | 148 +++++++++--------- 1 file changed, 77 insertions(+), 71 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py index 43e2ea5093..0dac040c7f 100644 --- a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py @@ -5,10 +5,56 @@ from openpype.pipeline import ( ) from openpype.hosts.fusion.api.action import SelectInvalidAction -from openpype.hosts.fusion.api import ( - get_current_comp, - comp_lock_and_undo_chunk, -) +from openpype.hosts.fusion.api import comp_lock_and_undo_chunk + + +def get_tool_resolution(tool, frame): + """Return the 2D input resolution to a Fusion tool + + If the current tool hasn't been rendered its input resolution + hasn't been saved. To combat this, add an expression in + the comments field to read the resolution + + Args + tool (Fusion Tool): The tool to query input resolution + frame (int): The frame to query the resolution on. + + Returns: + tuple: width, height as 2-tuple of integers + + """ + comp = tool.Composition + + # False undo removes the undo-stack from the undo list + with comp_lock_and_undo_chunk(comp, "Read resolution", False): + # Save old comment + old_comment = "" + has_expression = False + if tool["Comments"][frame] != "": + if tool["Comments"].GetExpression() is not None: + has_expression = True + old_comment = tool["Comments"].GetExpression() + tool["Comments"].SetExpression(None) + else: + old_comment = tool["Comments"][frame] + tool["Comments"][frame] = "" + + # Get input width + tool["Comments"].SetExpression("self.Input.OriginalWidth") + width = int(tool["Comments"][frame]) + + # Get input height + tool["Comments"].SetExpression("self.Input.OriginalHeight") + height = int(tool["Comments"][frame]) + + # Reset old comment + tool["Comments"].SetExpression(None) + if has_expression: + tool["Comments"].SetExpression(old_comment) + else: + tool["Comments"][frame] = old_comment + + return width, height class ValidateSaverResolution( @@ -23,74 +69,34 @@ class ValidateSaverResolution( optional = True actions = [SelectInvalidAction] - @classmethod - def get_invalid(cls, instance, wrong_resolution=None): - """Validate if the saver rescive the expected resolution""" - if wrong_resolution is None: - wrong_resolution = [] - - saver = instance[0] - firstFrame = saver.GetAttrs("TOOLNT_Region_Start")[1] - comp = get_current_comp() - - # If the current saver hasn't bin rendered its input resolution - # hasn't bin saved. To combat this, add an expression in - # the comments field to read the resolution - - # False undo removes the undo-stack from the undo list - with comp_lock_and_undo_chunk(comp, "Read resolution", False): - # Save old comment - oldComment = "" - hasExpression = False - if saver["Comments"][firstFrame] != "": - if saver["Comments"].GetExpression() is not None: - hasExpression = True - oldComment = saver["Comments"].GetExpression() - saver["Comments"].SetExpression(None) - else: - oldComment = saver["Comments"][firstFrame] - saver["Comments"][firstFrame] = "" - - # Get input width - saver["Comments"].SetExpression("self.Input.OriginalWidth") - width = int(saver["Comments"][firstFrame]) - - # Get input height - saver["Comments"].SetExpression("self.Input.OriginalHeight") - height = int(saver["Comments"][firstFrame]) - - # Reset old comment - saver["Comments"].SetExpression(None) - if hasExpression: - saver["Comments"].SetExpression(oldComment) - else: - saver["Comments"][firstFrame] = oldComment - - # Time to compare! - wrong_resolution.append("{}x{}".format(width, height)) - entityData = instance.data["assetEntity"]["data"] - if entityData["resolutionWidth"] != width: - return [saver] - if entityData["resolutionHeight"] != height: - return [saver] - - return [] - def process(self, instance): - if not self.is_active(instance.data): - return - - wrong_resolution = [] - invalid = self.get_invalid(instance, wrong_resolution) - if invalid: - entityData = instance.data["assetEntity"]["data"] + resolution = self.get_resolution(instance) + expected_resolution = self.get_expected_resolution(instance) + if resolution != expected_resolution: raise PublishValidationError( - "The input's resolution does not match" - " the asset's resolution of {}x{}.\n\n" + "The input's resolution does not match " + "the asset's resolution {}.\n\n" "The input's resolution is {}".format( - entityData["resolutionWidth"], - entityData["resolutionHeight"], - wrong_resolution[0], - ), - title=self.label, + expected_resolution, + resolution, + ) ) + + @classmethod + def get_invalid(cls, instance): + resolution = cls.get_resolution(instance) + expected_resolution = cls.get_expected_resolution(instance) + if resolution != expected_resolution: + saver = instance.data["tool"] + return [saver] + + @classmethod + def get_resolution(cls, instance): + saver = instance.data["tool"] + first_frame = saver.GetAttrs("TOOLNT_Region_Start")[1] + return get_tool_resolution(saver, frame=first_frame) + + @classmethod + def get_expected_resolution(cls, instance): + data = instance.data["assetEntity"]["data"] + return data["resolutionWidth"], data["resolutionHeight"] From 7b62a204a35bbd29ccbdfcc485020a63d1b26dee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 17:22:09 +0200 Subject: [PATCH 0086/1224] Fix optional support --- .../hosts/fusion/plugins/publish/validator_saver_resolution.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py index 0dac040c7f..1c38533cf9 100644 --- a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py @@ -70,6 +70,9 @@ class ValidateSaverResolution( actions = [SelectInvalidAction] def process(self, instance): + if not self.is_active(instance.data): + return + resolution = self.get_resolution(instance) expected_resolution = self.get_expected_resolution(instance) if resolution != expected_resolution: From 7a2cc22e6d2e4b949b78243a467547be63e56104 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 17:24:22 +0200 Subject: [PATCH 0087/1224] Fix docstring --- .../hosts/fusion/plugins/publish/validator_saver_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py index 1c38533cf9..8317864fcf 100644 --- a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py @@ -60,7 +60,7 @@ def get_tool_resolution(tool, frame): class ValidateSaverResolution( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): - """Validate that the saver input resolution matches the projects""" + """Validate that the saver input resolution matches the asset resolution""" order = pyblish.api.ValidatorOrder label = "Validate Saver Resolution" From c3e299f9adfd49b8f87a2be1a3ef58804cf22eb4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 17:26:13 +0200 Subject: [PATCH 0088/1224] Use `frameStartHandle` since we only care about the saver during the publish frame range --- .../hosts/fusion/plugins/publish/validator_saver_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py index 8317864fcf..bcc101ba16 100644 --- a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py @@ -96,7 +96,7 @@ class ValidateSaverResolution( @classmethod def get_resolution(cls, instance): saver = instance.data["tool"] - first_frame = saver.GetAttrs("TOOLNT_Region_Start")[1] + first_frame = instance.data["frameStartHandle"] return get_tool_resolution(saver, frame=first_frame) @classmethod From bed16ae663c7d4dbb7e4c32db72d7e5e53d277d4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 18:07:32 +0200 Subject: [PATCH 0089/1224] Match filename with other validators --- ...validator_saver_resolution.py => validate_saver_resolution.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename openpype/hosts/fusion/plugins/publish/{validator_saver_resolution.py => validate_saver_resolution.py} (100%) diff --git a/openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py similarity index 100% rename from openpype/hosts/fusion/plugins/publish/validator_saver_resolution.py rename to openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py From a50ab622b7968ec0bbf377f152db603b1cf1984c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 18:08:59 +0200 Subject: [PATCH 0090/1224] Fix message readability --- .../fusion/plugins/publish/validate_saver_resolution.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py index bcc101ba16..b43a5023fa 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py @@ -78,10 +78,10 @@ class ValidateSaverResolution( if resolution != expected_resolution: raise PublishValidationError( "The input's resolution does not match " - "the asset's resolution {}.\n\n" - "The input's resolution is {}".format( - expected_resolution, - resolution, + "the asset's resolution {}x{}.\n\n" + "The input's resolution is {}x{}.".format( + expected_resolution[0], expected_resolution[1], + resolution[0], resolution[1] ) ) From 49e29116414e87b92732094e5a5cdf95a5d8c8d7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 20:25:50 +0200 Subject: [PATCH 0091/1224] Expose ValidateSaverResolution in settings --- .../defaults/project_settings/fusion.json | 7 +++++++ .../projects_schema/schema_project_fusion.json | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 0ee7d6127d..ab24727db5 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -27,5 +27,12 @@ "farm_rendering" ] } + }, + "publish": { + "ValidateSaverResolution": { + "enabled": true, + "optional": true, + "active": true + } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json index 656c50dd98..342411f8a5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -84,6 +84,24 @@ ] } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateSaverResolution", + "label": "Validate Saver Resolution" + } + ] + } + ] } ] } 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 0092/1224] 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 0093/1224] 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 0094/1224] 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 0095/1224] 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 0096/1224] 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 0097/1224] 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 0098/1224] 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 0099/1224] 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 0100/1224] 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 0101/1224] 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 0102/1224] 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 0103/1224] :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 0104/1224] 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 0105/1224] 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 0106/1224] 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 0107/1224] 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 0108/1224] 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 0109/1224] :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 0110/1224] 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 0111/1224] 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 0112/1224] 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 0113/1224] 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 0114/1224] 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 0115/1224] 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 0116/1224] 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 0117/1224] 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 0118/1224] 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 0119/1224] 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 0120/1224] 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 0121/1224] 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 0122/1224] 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 0123/1224] 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 dbd03c6c5a2b851ca6a8708cf6e290fd498e5192 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 14 Sep 2023 08:38:51 +0100 Subject: [PATCH 0124/1224] Missing "data" field and enabling of audio --- openpype/hosts/maya/plugins/load/load_audio.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 265b15f4ae..eaaf81d873 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -61,6 +61,14 @@ class AudioLoader(load.LoaderPlugin): path = get_representation_path(representation) cmds.setAttr("{}.filename".format(audio_node), path, type="string") + + cmds.timeControl( + mel.eval("$tmpVar=$gPlayBackSlider"), + edit=True, + sound=audio_node, + displaySound=True + ) + cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), @@ -76,7 +84,7 @@ class AudioLoader(load.LoaderPlugin): project_name, version["parent"], fields=["parent"] ) asset = get_asset_by_id( - project_name, subset["parent"], fields=["parent"] + project_name, subset["parent"], fields=["parent", "data"] ) source_start = 1 - asset["data"]["frameStart"] From a2c72e683e2ac3420931a0721a9f3d12f843db96 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 17:10:46 +0800 Subject: [PATCH 0125/1224] 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 4d22b6cf4b31465833bc239b16e46ec2b1d1f942 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 11:53:06 +0100 Subject: [PATCH 0126/1224] Update openpype/hosts/maya/plugins/load/load_audio.py Co-authored-by: Roy Nieterau --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index eaaf81d873..7114d92daa 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -84,7 +84,7 @@ class AudioLoader(load.LoaderPlugin): project_name, version["parent"], fields=["parent"] ) asset = get_asset_by_id( - project_name, subset["parent"], fields=["parent", "data"] + project_name, subset["parent"], fields=["parent", "data.frameStart", "data.frameEnd"] ) source_start = 1 - asset["data"]["frameStart"] From a029031b58cd95663e018d3e8dd38a23e8475cdb Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 11:53:53 +0100 Subject: [PATCH 0127/1224] Update openpype/hosts/maya/plugins/load/load_audio.py --- openpype/hosts/maya/plugins/load/load_audio.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 7114d92daa..9c2fdfb6d3 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -84,7 +84,8 @@ class AudioLoader(load.LoaderPlugin): project_name, version["parent"], fields=["parent"] ) asset = get_asset_by_id( - project_name, subset["parent"], fields=["parent", "data.frameStart", "data.frameEnd"] + project_name, subset["parent"], + fields=["parent", "data.frameStart", "data.frameEnd"] ) source_start = 1 - asset["data"]["frameStart"] From 887127828887d0642d1e77c2f92b7f3a441135a7 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 11:54:14 +0100 Subject: [PATCH 0128/1224] Update openpype/hosts/maya/plugins/load/load_audio.py --- openpype/hosts/maya/plugins/load/load_audio.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 9c2fdfb6d3..17c7d442ae 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -84,7 +84,8 @@ class AudioLoader(load.LoaderPlugin): project_name, version["parent"], fields=["parent"] ) asset = get_asset_by_id( - project_name, subset["parent"], + project_name, + subset["parent"], fields=["parent", "data.frameStart", "data.frameEnd"] ) From 3903071f21f2d2e7ec426d287fe1713c8cf7122b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 14 Sep 2023 11:55:50 +0100 Subject: [PATCH 0129/1224] Check current sound on timeline --- .../hosts/maya/plugins/load/load_audio.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 17c7d442ae..6e2f2e89bc 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -59,15 +59,23 @@ class AudioLoader(load.LoaderPlugin): assert audio_nodes is not None, "Audio node not found." audio_node = audio_nodes[0] + current_sound = cmds.timeControl( + mel.eval("$tmpVar=$gPlayBackSlider"), + query=True, + sound=True + ) + activate_sound = current_sound == audio_node + path = get_representation_path(representation) cmds.setAttr("{}.filename".format(audio_node), path, type="string") - cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), - edit=True, - sound=audio_node, - displaySound=True - ) + if activate_sound: + cmds.timeControl( + mel.eval("$tmpVar=$gPlayBackSlider"), + edit=True, + sound=audio_node, + displaySound=True + ) cmds.setAttr( container["objectName"] + ".representation", From 39dad459d588486a5711cecf79419c24d99524ed Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 15:16:28 +0100 Subject: [PATCH 0130/1224] Update openpype/hosts/maya/plugins/load/load_audio.py --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 6e2f2e89bc..fedb985e0b 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -60,7 +60,7 @@ class AudioLoader(load.LoaderPlugin): audio_node = audio_nodes[0] current_sound = cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), query=True, sound=True ) From 2c2aef60de3b9e66e3efb79cf2d01578dcf829df Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 15:16:52 +0100 Subject: [PATCH 0131/1224] Update openpype/hosts/maya/plugins/load/load_audio.py --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index fedb985e0b..7750d41e97 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -71,7 +71,7 @@ class AudioLoader(load.LoaderPlugin): if activate_sound: cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=audio_node, displaySound=True From a4b1797b2abc2869d15f343f8f00894783fa5270 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 14 Sep 2023 15:18:13 +0100 Subject: [PATCH 0132/1224] tmpVar > gPlayBackSlider --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 7750d41e97..d3a670398b 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -30,7 +30,7 @@ class AudioLoader(load.LoaderPlugin): file=context["representation"]["data"]["path"], offset=start_frame ) cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=sound_node, displaySound=True From 2c2b5a35057ed10d060f5fc645b9ed53f5e5560c Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 14 Sep 2023 15:39:00 +0100 Subject: [PATCH 0133/1224] Update openpype/hosts/maya/plugins/load/load_audio.py Co-authored-by: Roy Nieterau --- openpype/hosts/maya/plugins/load/load_audio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index d3a670398b..2da5a6f1c2 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -70,6 +70,7 @@ class AudioLoader(load.LoaderPlugin): cmds.setAttr("{}.filename".format(audio_node), path, type="string") if activate_sound: + # maya by default deactivates it from timeline on file change cmds.timeControl( mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, From 6c8e162fa86f995168fb69428510705b95e0e9e7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 14 Sep 2023 19:27:36 +0200 Subject: [PATCH 0134/1224] :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 f6de6d07bc7e59575c54b81b531d165fae822fbf Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 21:01:14 +0300 Subject: [PATCH 0135/1224] add self publish button --- openpype/hosts/houdini/api/lib.py | 62 ++++++++++++++++++++++++++++ openpype/hosts/houdini/api/plugin.py | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75c7ff9fee..755368616b 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -649,3 +649,65 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def publisher_show_and_publish(): + """Open publisher window and trigger publishing action. """ + + from openpype.tools.utils.host_tools import get_tool_by_name + + main_window = get_main_window() + publisher_window = get_tool_by_name( + tool_name="publisher", + parent=main_window + ) + + publisher_window.set_current_tab("publish") + publisher_window.make_sure_is_visible() + publisher_window._reset_on_show = False + + publisher_window._controller.reset() + publisher_window._controller.publish() + + +def self_publish(): + """Self publish from ROP nodes. """ + from openpype.pipeline import registered_host + from openpype.pipeline.create import CreateContext + + current_node = hou.node(".").path() + + host = registered_host() + context = CreateContext(host, reset=True) + + for instance in context.instances: + node_path = instance.data.get("instance_node") + if not node_path: + continue + print(node_path) + + if current_node == node_path: + instance["active"] = True + else: + instance["active"] = False + + context.save_changes() + publisher_show_and_publish() + +def add_self_publish_button(node): + """Adds a self publish button in the rop node. """ + label = os.environ.get("AVALON_LABEL") or "OpenPype" + + button_parm = hou.ButtonParmTemplate( + "{}_publish".format(label.lower()), + "{} Publish".format(label), + script_callback="from openpype.hosts.houdini.api.lib import " + "self_publish; self_publish()", + script_callback_language=hou.scriptLanguage.Python, + join_with_next=True + ) + + template = node.parmTemplateGroup() + template.insertBefore((0,), button_parm) + # parm_group.append(button_parm) + node.setParmTemplateGroup(template) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 730a627dc3..756b33f7f7 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -13,7 +13,7 @@ from openpype.pipeline import ( CreatedInstance ) from openpype.lib import BoolDef -from .lib import imprint, read, lsattr +from .lib import imprint, read, lsattr, add_self_publish_button class OpenPypeCreatorError(CreatorError): @@ -194,6 +194,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self) self._add_instance_to_context(instance) imprint(instance_node, instance.data_to_store()) + add_self_publish_button(instance_node) return instance except hou.Error as er: From dc065171f9452bae9994835e3bdcacdae12f431b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 21:16:17 +0300 Subject: [PATCH 0136/1224] resolve hound --- 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 755368616b..a91f319ae8 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -694,6 +694,7 @@ def self_publish(): context.save_changes() publisher_show_and_publish() + def add_self_publish_button(node): """Adds a self publish button in the rop node. """ label = os.environ.get("AVALON_LABEL") or "OpenPype" From 2d8034ae77bccb4dd54438dad023bd3b15e7e93d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 23:36:16 +0300 Subject: [PATCH 0137/1224] BigRoy's Comment --- openpype/hosts/houdini/api/lib.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a91f319ae8..c175b32b83 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -10,8 +10,14 @@ import json import six from openpype.client import get_asset_by_name -from openpype.pipeline import get_current_project_name, get_current_asset_name +from openpype.pipeline import ( + get_current_project_name, + get_current_asset_name, + registered_host +) from openpype.pipeline.context_tools import get_current_project_asset +from openpype.tools.utils.host_tools import get_tool_by_name +from openpype.pipeline.create import CreateContext import hou @@ -652,9 +658,7 @@ def get_color_management_preferences(): def publisher_show_and_publish(): - """Open publisher window and trigger publishing action. """ - - from openpype.tools.utils.host_tools import get_tool_by_name + """Open publisher window and trigger publishing action.""" main_window = get_main_window() publisher_window = get_tool_by_name( @@ -671,9 +675,7 @@ def publisher_show_and_publish(): def self_publish(): - """Self publish from ROP nodes. """ - from openpype.pipeline import registered_host - from openpype.pipeline.create import CreateContext + """Self publish from ROP nodes.""" current_node = hou.node(".").path() @@ -684,19 +686,16 @@ def self_publish(): node_path = instance.data.get("instance_node") if not node_path: continue - print(node_path) - if current_node == node_path: - instance["active"] = True - else: - instance["active"] = False + instance["active"] = current_node == node_path context.save_changes() publisher_show_and_publish() def add_self_publish_button(node): - """Adds a self publish button in the rop node. """ + """Adds a self publish button in the rop node.""" + label = os.environ.get("AVALON_LABEL") or "OpenPype" button_parm = hou.ButtonParmTemplate( @@ -710,5 +709,4 @@ def add_self_publish_button(node): template = node.parmTemplateGroup() template.insertBefore((0,), button_parm) - # parm_group.append(button_parm) node.setParmTemplateGroup(template) From a4f46380657dc24924627dc877cfa59312d572b0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 10:35:50 +0300 Subject: [PATCH 0138/1224] add publish comment --- openpype/hosts/houdini/api/lib.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index c175b32b83..b6b551a592 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -687,7 +687,20 @@ def self_publish(): if not node_path: continue - instance["active"] = current_node == node_path + active = current_node == node_path + if not active: + continue + + instance["active"] = active + + result, comment = hou.ui.readInput( + "Add Publish Note", + buttons=("Ok", "Cancel"), + title="Publish Note", + close_choice=1 + ) + if not result: + instance.data["comment"] = comment context.save_changes() publisher_show_and_publish() From e48090757f6f7c9b88abc8779d16e5f26078db87 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 12:15:15 +0300 Subject: [PATCH 0139/1224] fix bug - disable other instances --- openpype/hosts/houdini/api/lib.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index b6b551a592..7e346d7285 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -688,19 +688,17 @@ def self_publish(): continue active = current_node == node_path - if not active: - continue - instance["active"] = active - result, comment = hou.ui.readInput( - "Add Publish Note", - buttons=("Ok", "Cancel"), - title="Publish Note", - close_choice=1 - ) - if not result: - instance.data["comment"] = comment + if active: + result, comment = hou.ui.readInput( + "Add Publish Note", + buttons=("Ok", "Cancel"), + title="Publish Note", + close_choice=1 + ) + if not result: + instance.data["comment"] = comment context.save_changes() publisher_show_and_publish() From 5f0ce4f88dd09caee68a04e94db672620c6d0416 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Sep 2023 18:38:40 +0800 Subject: [PATCH 0140/1224] add tycache family --- .../max/plugins/create/create_tycache.py | 34 ++++ .../hosts/max/plugins/load/load_tycache.py | 67 +++++++ .../max/plugins/publish/extract_tycache.py | 183 ++++++++++++++++++ .../plugins/publish/validate_pointcloud.py | 59 +----- .../plugins/publish/validate_tyflow_data.py | 74 +++++++ 5 files changed, 361 insertions(+), 56 deletions(-) create mode 100644 openpype/hosts/max/plugins/create/create_tycache.py create mode 100644 openpype/hosts/max/plugins/load/load_tycache.py create mode 100644 openpype/hosts/max/plugins/publish/extract_tycache.py create mode 100644 openpype/hosts/max/plugins/publish/validate_tyflow_data.py diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py new file mode 100644 index 0000000000..0fe0f32eed --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating TyCache.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import EnumDef + + +class CreateTyCache(plugin.MaxCreator): + """Creator plugin for TyCache.""" + identifier = "io.openpype.creators.max.tycache" + label = "TyCache" + family = "tycache" + icon = "gear" + + def create(self, subset_name, instance_data, pre_create_data): + from pymxs import runtime as rt + instance_data = pre_create_data.get("tycache_type") + super(CreateTyCache, self).create( + subset_name, + instance_data, + pre_create_data) + + def get_pre_create_attr_defs(self): + attrs = super(CreateTyCache, self).get_pre_create_attr_defs() + + tycache_format_enum = ["tycache", "tycachespline"] + + + return attrs + [ + + EnumDef("tycache_type", + tycache_format_enum, + default="tycache", + label="TyCache Type") + ] diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py new file mode 100644 index 0000000000..657e743087 --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -0,0 +1,67 @@ +import os + +from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import ( + unique_namespace, + +) +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) +from openpype.pipeline import get_representation_path, load + + +class PointCloudLoader(load.LoaderPlugin): + """Point Cloud Loader.""" + + families = ["tycache"] + representations = ["tyc"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + """Load tyCache""" + from pymxs import runtime as rt + filepath = os.path.normpath(self.filepath_from_context(context)) + obj = rt.tyCache() + obj.filename = filepath + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + obj.name = f"{namespace}:{obj.name}" + + return containerise( + name, [obj], context, + namespace, loader=self.__class__.__name__) + + def update(self, container, representation): + """update the container""" + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.GetNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + update_custom_attribute_data( + node, node_list) + with maintained_selection(): + rt.Select(node_list) + for prt in rt.Selection: + prt.filename = path + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + """remove the container""" + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py new file mode 100644 index 0000000000..8fcdd6d65c --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -0,0 +1,183 @@ +import os + +import pyblish.api +from pymxs import runtime as rt + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import publish + + +class ExtractTyCache(publish.Extractor): + """ + Extract tycache format with tyFlow operators. + Notes: + - TyCache only works for TyFlow Pro Plugin. + + Args: + self.export_particle(): sets up all job arguments for attributes + to be exported in MAXscript + + self.get_operators(): get the export_particle operator + + self.get_files(): get the files with tyFlow naming convention + before publishing + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract TyCache" + hosts = ["max"] + families = ["tycache"] + + def process(self, instance): + # TODO: let user decide the param + start = int(instance.context.data.get("frameStart")) + end = int(instance.context.data.get("frameEnd")) + self.log.info("Extracting Tycache...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.tyc".format(**instance.data) + path = os.path.join(stagingdir, filename) + filenames = self.get_file(path, start, end) + with maintained_selection(): + job_args = None + if instance.data["tycache_type"] == "tycache": + job_args = self.export_particle( + instance.data["members"], + start, end, path) + elif instance.data["tycache_type"] == "tycachespline": + job_args = self.export_particle( + instance.data["members"], + start, end, path, + tycache_spline_enabled=True) + + for job in job_args: + rt.Execute(job) + + representation = { + 'name': 'tyc', + 'ext': 'tyc', + 'files': filenames if len(filenames) > 1 else filenames[0], + "stagingDir": stagingdir + } + instance.data["representations"].append(representation) + self.log.info(f"Extracted instance '{instance.name}' to: {path}") + + def get_file(self, filepath, start_frame, end_frame): + filenames = [] + filename = os.path.basename(filepath) + orig_name, _ = os.path.splitext(filename) + for frame in range(int(start_frame), int(end_frame) + 1): + actual_name = "{}_{:05}".format(orig_name, frame) + actual_filename = filepath.replace(orig_name, actual_name) + filenames.append(os.path.basename(actual_filename)) + + return filenames + + def export_particle(self, members, start, end, + filepath, tycache_spline_enabled=False): + """Sets up all job arguments for attributes. + + Those attributes are to be exported in MAX Script. + + Args: + members (list): Member nodes of the instance. + start (int): Start frame. + end (int): End frame. + filepath (str): Output path of the TyCache file. + + Returns: + list of arguments for MAX Script. + + """ + job_args = [] + opt_list = self.get_operators(members) + for operator in opt_list: + if tycache_spline_enabled: + export_mode = f'{operator}.exportMode=3' + has_tyc_spline = f'{operator}.tycacheSplines=true' + job_args.extend([export_mode, has_tyc_spline]) + else: + export_mode = f'{operator}.exportMode=2' + job_args.append(export_mode) + start_frame = f"{operator}.frameStart={start}" + job_args.append(start_frame) + end_frame = f"{operator}.frameEnd={end}" + job_args.append(end_frame) + filepath = filepath.replace("\\", "/") + tycache_filename = f'{operator}.tyCacheFilename="{filepath}"' + job_args.append(tycache_filename) + additional_args = self.get_custom_attr(operator) + job_args.extend(iter(additional_args)) + tycache_export = f"{operator}.exportTyCache()" + job_args.append(tycache_export) + + return job_args + + @staticmethod + def get_operators(members): + """Get Export Particles Operator. + + Args: + members (list): Instance members. + + Returns: + list of particle operators + + """ + opt_list = [] + for member in members: + obj = member.baseobject + # TODO: to see if it can be used maxscript instead + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + sub_anim = rt.GetSubAnim(obj, anim_name) + boolean = rt.IsProperty(sub_anim, "Export_Particles") + if boolean: + event_name = sub_anim.Name + opt = f"${member.Name}.{event_name}.export_particles" + opt_list.append(opt) + + return opt_list + +""" +.exportMode : integer +.frameStart : integer +.frameEnd : integer + + .tycacheChanAge : boolean + .tycacheChanGroups : boolean + .tycacheChanPos : boolean + .tycacheChanRot : boolean + .tycacheChanScale : boolean + .tycacheChanVel : boolean + .tycacheChanSpin : boolean + .tycacheChanShape : boolean + .tycacheChanMatID : boolean + .tycacheChanMapping : boolean + .tycacheChanMaterials : boolean + .tycacheChanCustomFloat : boolean + .tycacheChanCustomVector : boolean + .tycacheChanCustomTM : boolean + .tycacheChanPhysX : boolean + .tycacheMeshBackup : boolean + .tycacheCreateObject : boolean + .tycacheCreateObjectIfNotCreated : boolean + .tycacheLayer : string + .tycacheObjectName : string + .tycacheAdditionalCloth : boolean + .tycacheAdditionalSkin : boolean + .tycacheAdditionalSkinID : boolean + .tycacheAdditionalSkinIDValue : integer + .tycacheAdditionalTerrain : boolean + .tycacheAdditionalVDB : boolean + .tycacheAdditionalSplinePaths : boolean + .tycacheAdditionalTyMesher : boolean + .tycacheAdditionalGeo : boolean + .tycacheAdditionalObjectList_deprecated : node array + .tycacheAdditionalObjectList : maxObject array + .tycacheAdditionalGeoActivateModifiers : boolean + .tycacheSplinesAdditionalSplines : boolean + .tycacheSplinesAdditionalSplinesObjectList_deprecated : node array + .tycacheSplinesAdditionalObjectList : maxObject array + +""" diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index 295a23f1f6..3ccc9dfda8 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -14,29 +14,16 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def process(self, instance): """ Notes: - - 1. Validate the container only include tyFlow objects - 2. Validate if tyFlow operator Export Particle exists - 3. Validate if the export mode of Export Particle is at PRT format - 4. Validate the partition count and range set as default value + 1. Validate if the export mode of Export Particle is at PRT format + 2. Validate the partition count and range set as default value Partition Count : 100 Partition Range : 1 to 1 - 5. Validate if the custom attribute(s) exist as parameter(s) + 3. Validate if the custom attribute(s) exist as parameter(s) of export_particle operator """ report = [] - invalid_object = self.get_tyflow_object(instance) - if invalid_object: - report.append(f"Non tyFlow object found: {invalid_object}") - - invalid_operator = self.get_tyflow_operator(instance) - if invalid_operator: - report.append(( - "tyFlow ExportParticle operator not " - f"found: {invalid_operator}")) - if self.validate_export_mode(instance): report.append("The export mode is not at PRT") @@ -52,46 +39,6 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): if report: raise PublishValidationError(f"{report}") - def get_tyflow_object(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow container for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - sel_tmp = str(sel) - if rt.ClassOf(sel) in [rt.tyFlow, - rt.Editable_Mesh]: - if "tyFlow" not in sel_tmp: - invalid.append(sel) - else: - invalid.append(sel) - - return invalid - - def get_tyflow_operator(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow object for {container}") - selection_list = instance.data["members"] - bool_list = [] - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - # check if there is export particle operator - boolean = rt.IsProperty(sub_anim, "Export_Particles") - bool_list.append(str(boolean)) - # if the export_particles property is not there - # it means there is not a "Export Particle" operator - if "True" not in bool_list: - self.log.error("Operator 'Export Particles' not found!") - invalid.append(sel) - - return invalid - def validate_custom_attribute(self, instance): invalid = [] container = instance.data["instance_node"] diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py new file mode 100644 index 0000000000..de8d161b9d --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -0,0 +1,74 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidatePointCloud(pyblish.api.InstancePlugin): + """Validate that TyFlow plugins or + relevant operators being set correctly.""" + + order = pyblish.api.ValidatorOrder + families = ["pointcloud", "tycache"] + hosts = ["max"] + label = "TyFlow Data" + + def process(self, instance): + """ + Notes: + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + + """ + report = [] + + invalid_object = self.get_tyflow_object(instance) + if invalid_object: + report.append(f"Non tyFlow object found: {invalid_object}") + + invalid_operator = self.get_tyflow_operator(instance) + if invalid_operator: + report.append(( + "tyFlow ExportParticle operator not " + f"found: {invalid_operator}")) + if report: + raise PublishValidationError(f"{report}") + + def get_tyflow_object(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info(f"Validating tyFlow container for {container}") + + selection_list = instance.data["members"] + for sel in selection_list: + sel_tmp = str(sel) + if rt.ClassOf(sel) in [rt.tyFlow, + rt.Editable_Mesh]: + if "tyFlow" not in sel_tmp: + invalid.append(sel) + else: + invalid.append(sel) + + return invalid + + def get_tyflow_operator(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info(f"Validating tyFlow object for {container}") + selection_list = instance.data["members"] + bool_list = [] + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.GetSubAnim(obj, anim_name) + # check if there is export particle operator + boolean = rt.IsProperty(sub_anim, "Export_Particles") + bool_list.append(str(boolean)) + # if the export_particles property is not there + # it means there is not a "Export Particle" operator + if "True" not in bool_list: + self.log.error("Operator 'Export Particles' not found!") + invalid.append(sel) + + return invalid From 96638726a90896673457dccc91f5bec5fd069ae9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 13:09:32 +0100 Subject: [PATCH 0141/1224] 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 b7ceeaa3542046c20899ac3e3979a40a3ff3410e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Sep 2023 20:21:34 +0800 Subject: [PATCH 0142/1224] hound & add tycache family into integrate --- .../max/plugins/create/create_tycache.py | 4 +-- .../max/plugins/publish/extract_tycache.py | 28 ++++++++++++++++--- .../plugins/publish/collect_resources_path.py | 3 +- openpype/plugins/publish/integrate.py | 3 +- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py index 0fe0f32eed..b99ca37c9b 100644 --- a/openpype/hosts/max/plugins/create/create_tycache.py +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -12,7 +12,6 @@ class CreateTyCache(plugin.MaxCreator): icon = "gear" def create(self, subset_name, instance_data, pre_create_data): - from pymxs import runtime as rt instance_data = pre_create_data.get("tycache_type") super(CreateTyCache, self).create( subset_name, @@ -24,11 +23,10 @@ class CreateTyCache(plugin.MaxCreator): tycache_format_enum = ["tycache", "tycachespline"] - return attrs + [ EnumDef("tycache_type", tycache_format_enum, default="tycache", label="TyCache Type") - ] + ] diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 8fcdd6d65c..9bef175d27 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -5,6 +5,7 @@ from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.pipeline import publish +from openpype.lib import EnumDef class ExtractTyCache(publish.Extractor): @@ -93,7 +94,7 @@ class ExtractTyCache(publish.Extractor): opt_list = self.get_operators(members) for operator in opt_list: if tycache_spline_enabled: - export_mode = f'{operator}.exportMode=3' + export_mode = f'{operator}.exportMode=3' has_tyc_spline = f'{operator}.tycacheSplines=true' job_args.extend([export_mode, has_tyc_spline]) else: @@ -133,12 +134,31 @@ class ExtractTyCache(publish.Extractor): sub_anim = rt.GetSubAnim(obj, anim_name) boolean = rt.IsProperty(sub_anim, "Export_Particles") if boolean: - event_name = sub_anim.Name - opt = f"${member.Name}.{event_name}.export_particles" - opt_list.append(opt) + event_name = sub_anim.Name + opt = f"${member.Name}.{event_name}.export_particles" + opt_list.append(opt) return opt_list + def get_custom_attr(operators): + additonal_args = + ] + + + @classmethod + def get_attribute_defs(cls): + tycache_enum ={ + "Age": "tycacheChanAge", + "Groups": "tycacheChanGroups", + } + return [ + EnumDef("dspGeometry", + items=tycache_enum, + default="", + multiselection=True) + ] + + """ .exportMode : integer .frameStart : integer diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index f96dd0ae18..2fa944718f 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -62,7 +62,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "effect", "staticMesh", "skeletalMesh", - "xgen" + "xgen", + "tycache" ] def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 7e48155b9e..2b4c054fdc 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -139,7 +139,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "tycache" ] default_template_name = "publish" From ab90af0f5e485f069550dde8ffa83697b8bb03dc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Sep 2023 20:23:17 +0800 Subject: [PATCH 0143/1224] hound --- .../max/plugins/publish/extract_tycache.py | 65 +------------------ 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 9bef175d27..bbb6dc115f 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -107,8 +107,7 @@ class ExtractTyCache(publish.Extractor): filepath = filepath.replace("\\", "/") tycache_filename = f'{operator}.tyCacheFilename="{filepath}"' job_args.append(tycache_filename) - additional_args = self.get_custom_attr(operator) - job_args.extend(iter(additional_args)) + # TODO: add the additional job args for tycache attributes tycache_export = f"{operator}.exportTyCache()" job_args.append(tycache_export) @@ -139,65 +138,3 @@ class ExtractTyCache(publish.Extractor): opt_list.append(opt) return opt_list - - def get_custom_attr(operators): - additonal_args = - ] - - - @classmethod - def get_attribute_defs(cls): - tycache_enum ={ - "Age": "tycacheChanAge", - "Groups": "tycacheChanGroups", - } - return [ - EnumDef("dspGeometry", - items=tycache_enum, - default="", - multiselection=True) - ] - - -""" -.exportMode : integer -.frameStart : integer -.frameEnd : integer - - .tycacheChanAge : boolean - .tycacheChanGroups : boolean - .tycacheChanPos : boolean - .tycacheChanRot : boolean - .tycacheChanScale : boolean - .tycacheChanVel : boolean - .tycacheChanSpin : boolean - .tycacheChanShape : boolean - .tycacheChanMatID : boolean - .tycacheChanMapping : boolean - .tycacheChanMaterials : boolean - .tycacheChanCustomFloat : boolean - .tycacheChanCustomVector : boolean - .tycacheChanCustomTM : boolean - .tycacheChanPhysX : boolean - .tycacheMeshBackup : boolean - .tycacheCreateObject : boolean - .tycacheCreateObjectIfNotCreated : boolean - .tycacheLayer : string - .tycacheObjectName : string - .tycacheAdditionalCloth : boolean - .tycacheAdditionalSkin : boolean - .tycacheAdditionalSkinID : boolean - .tycacheAdditionalSkinIDValue : integer - .tycacheAdditionalTerrain : boolean - .tycacheAdditionalVDB : boolean - .tycacheAdditionalSplinePaths : boolean - .tycacheAdditionalTyMesher : boolean - .tycacheAdditionalGeo : boolean - .tycacheAdditionalObjectList_deprecated : node array - .tycacheAdditionalObjectList : maxObject array - .tycacheAdditionalGeoActivateModifiers : boolean - .tycacheSplinesAdditionalSplines : boolean - .tycacheSplinesAdditionalSplinesObjectList_deprecated : node array - .tycacheSplinesAdditionalObjectList : maxObject array - -""" From d6dc61c031a1661485bf5234ed72590480ac3fd9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Sep 2023 21:54:29 +0800 Subject: [PATCH 0144/1224] make sure all instanceplugins got the right class --- openpype/hosts/max/plugins/create/create_tycache.py | 5 +++-- openpype/hosts/max/plugins/publish/extract_tycache.py | 3 +-- openpype/hosts/max/plugins/publish/validate_tyflow_data.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py index b99ca37c9b..c48094028a 100644 --- a/openpype/hosts/max/plugins/create/create_tycache.py +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating TyCache.""" from openpype.hosts.max.api import plugin -from openpype.pipeline import EnumDef +from openpype.lib import EnumDef class CreateTyCache(plugin.MaxCreator): @@ -12,7 +12,8 @@ class CreateTyCache(plugin.MaxCreator): icon = "gear" def create(self, subset_name, instance_data, pre_create_data): - instance_data = pre_create_data.get("tycache_type") + instance_data["tycache_type"] = pre_create_data.get( + "tycache_type") super(CreateTyCache, self).create( subset_name, instance_data, diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index bbb6dc115f..242d12bd4c 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -5,7 +5,6 @@ from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.pipeline import publish -from openpype.lib import EnumDef class ExtractTyCache(publish.Extractor): @@ -61,7 +60,7 @@ class ExtractTyCache(publish.Extractor): "stagingDir": stagingdir } instance.data["representations"].append(representation) - self.log.info(f"Extracted instance '{instance.name}' to: {path}") + self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") def get_file(self, filepath, start_frame, end_frame): filenames = [] diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index de8d161b9d..4574950495 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -3,7 +3,7 @@ from openpype.pipeline import PublishValidationError from pymxs import runtime as rt -class ValidatePointCloud(pyblish.api.InstancePlugin): +class ValidateTyFlowData(pyblish.api.InstancePlugin): """Validate that TyFlow plugins or relevant operators being set correctly.""" From 2f5494cc76e25e4e5b7bdc3e743da0e279d8c256 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 17:02:49 +0300 Subject: [PATCH 0145/1224] make few publisher attributes and methods public --- openpype/hosts/houdini/api/lib.py | 34 +++++++++++++++--------------- openpype/tools/publisher/window.py | 14 ++++++++++++ openpype/tools/utils/host_tools.py | 12 +++++++---- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 7e346d7285..688916a507 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -657,21 +657,22 @@ def get_color_management_preferences(): } -def publisher_show_and_publish(): +def publisher_show_and_publish(comment = ""): """Open publisher window and trigger publishing action.""" main_window = get_main_window() publisher_window = get_tool_by_name( tool_name="publisher", - parent=main_window + parent=main_window, + reset_on_show=False ) publisher_window.set_current_tab("publish") publisher_window.make_sure_is_visible() - publisher_window._reset_on_show = False - - publisher_window._controller.reset() - publisher_window._controller.publish() + publisher_window.reset_on_show = False + publisher_window.set_comment_input_text(comment) + publisher_window.reset() + publisher_window.click_publish() def self_publish(): @@ -689,19 +690,18 @@ def self_publish(): active = current_node == node_path instance["active"] = active - - if active: - result, comment = hou.ui.readInput( - "Add Publish Note", - buttons=("Ok", "Cancel"), - title="Publish Note", - close_choice=1 - ) - if not result: - instance.data["comment"] = comment + hou.node(node_path).parm("active").set(active) context.save_changes() - publisher_show_and_publish() + + result, comment = hou.ui.readInput( + "Add Publish Note", + buttons=("Ok", "Cancel"), + title="Publish Note", + close_choice=1 + ) + + publisher_show_and_publish(comment) def add_self_publish_button(node): diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 39e78c01bb..9214c0a43f 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -388,6 +388,20 @@ class PublisherWindow(QtWidgets.QDialog): def controller(self): return self._controller + @property + def reset_on_show(self): + return self._reset_on_show + + @reset_on_show.setter + def reset_on_show(self, value): + self._reset_on_show = value + + def set_comment_input_text(self, text=""): + self._comment_input.setText(text) + + def click_publish(self): + self._on_publish_clicked() + def make_sure_is_visible(self): if self._window_is_visible: self.setWindowState(QtCore.Qt.WindowActive) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 2ebc973a47..3e891e1847 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -261,7 +261,7 @@ class HostToolsHelper: dialog.activateWindow() dialog.showNormal() - def get_publisher_tool(self, parent=None, controller=None): + def get_publisher_tool(self, parent=None, controller=None, reset_on_show=None): """Create, cache and return publisher window.""" if self._publisher_tool is None: @@ -271,15 +271,19 @@ class HostToolsHelper: ILoadHost.validate_load_methods(host) publisher_window = PublisherWindow( - controller=controller, parent=parent or self._parent + controller=controller, + parent=parent or self._parent, + reset_on_show=reset_on_show ) self._publisher_tool = publisher_window return self._publisher_tool - def show_publisher_tool(self, parent=None, controller=None, tab=None): + def show_publisher_tool( + self, parent=None, controller=None, reset_on_show=None, tab=None + ): with qt_app_context(): - window = self.get_publisher_tool(parent, controller) + window = self.get_publisher_tool(parent, controller, reset_on_show) if tab: window.set_current_tab(tab) window.make_sure_is_visible() From 53a626e9ac69cf2239e527a7bc4c6154102374c6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 17:04:43 +0300 Subject: [PATCH 0146/1224] resolve hound --- openpype/hosts/houdini/api/lib.py | 2 +- openpype/tools/utils/host_tools.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 688916a507..0bde308263 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -657,7 +657,7 @@ def get_color_management_preferences(): } -def publisher_show_and_publish(comment = ""): +def publisher_show_and_publish(comment=""): """Open publisher window and trigger publishing action.""" main_window = get_main_window() diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 3e891e1847..6885fb86c1 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -261,7 +261,9 @@ class HostToolsHelper: dialog.activateWindow() dialog.showNormal() - def get_publisher_tool(self, parent=None, controller=None, reset_on_show=None): + def get_publisher_tool( + self, parent=None, controller=None, reset_on_show=None + ): """Create, cache and return publisher window.""" if self._publisher_tool is None: From 28a3cf943dec1320b070381982cf44f20cc6fd1e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Sep 2023 22:26:08 +0800 Subject: [PATCH 0147/1224] finished up the extractor --- .../publish/collect_tycache_attributes.py | 70 +++++++++++++++++++ .../max/plugins/publish/extract_tycache.py | 38 ++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/collect_tycache_attributes.py diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py new file mode 100644 index 0000000000..e312dd8826 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -0,0 +1,70 @@ +import pyblish.api + +from openpype.lib import EnumDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin + + +class CollectTyCacheData(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect Review Data for Preview Animation""" + + order = pyblish.api.CollectorOrder + 0.02 + label = "Collect tyCache attribute Data" + hosts = ['max'] + families = ["tycache"] + + def process(self, instance): + all_tyc_attributes_dict = {} + attr_values = self.get_attr_values_from_data(instance.data) + tycache_boolean_attributes = attr_values.get("all_tyc_attrs") + if tycache_boolean_attributes: + for attrs in tycache_boolean_attributes: + all_tyc_attributes_dict[attrs] = True + self.log.debug(f"Found tycache attributes: {tycache_boolean_attributes}") + + @classmethod + def get_attribute_defs(cls): + tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", "tycacheChanPos", + "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", + "tycacheChanSpin", "tycacheChanShape", "tycacheChanMatID", + "tycacheChanMapping", "tycacheChanMaterials", + "tycacheChanCustomFloat" + ] + + return [ + EnumDef("all_tyc_attrs", + tyc_attr_enum, + default=None, + multiselection=True + + ) + ] +""" + + .tycacheChanCustomFloat : boolean + .tycacheChanCustomVector : boolean + .tycacheChanCustomTM : boolean + .tycacheChanPhysX : boolean + .tycacheMeshBackup : boolean + .tycacheCreateObject : boolean + .tycacheCreateObjectIfNotCreated : boolean + .tycacheLayer : string + .tycacheObjectName : string + .tycacheAdditionalCloth : boolean + .tycacheAdditionalSkin : boolean + .tycacheAdditionalSkinID : boolean + .tycacheAdditionalSkinIDValue : integer + .tycacheAdditionalTerrain : boolean + .tycacheAdditionalVDB : boolean + .tycacheAdditionalSplinePaths : boolean + .tycacheAdditionalTyMesher : boolean + .tycacheAdditionalGeo : boolean + .tycacheAdditionalObjectList_deprecated : node array + .tycacheAdditionalObjectList : maxObject array + .tycacheAdditionalGeoActivateModifiers : boolean + .tycacheSplines: boolean + .tycacheSplinesAdditionalSplines : boolean + .tycacheSplinesAdditionalSplinesObjectList_deprecated : node array + .tycacheSplinesAdditionalObjectList : maxObject array + +""" diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 242d12bd4c..e98fad5c2b 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -38,16 +38,20 @@ class ExtractTyCache(publish.Extractor): filename = "{name}.tyc".format(**instance.data) path = os.path.join(stagingdir, filename) filenames = self.get_file(path, start, end) + additional_attributes = instance.data.get("tyc_attrs", {}) + with maintained_selection(): job_args = None if instance.data["tycache_type"] == "tycache": job_args = self.export_particle( instance.data["members"], - start, end, path) + start, end, path, + additional_attributes) elif instance.data["tycache_type"] == "tycachespline": job_args = self.export_particle( instance.data["members"], start, end, path, + additional_attributes, tycache_spline_enabled=True) for job in job_args: @@ -74,7 +78,8 @@ class ExtractTyCache(publish.Extractor): return filenames def export_particle(self, members, start, end, - filepath, tycache_spline_enabled=False): + filepath, additional_attributes, + tycache_spline_enabled=False): """Sets up all job arguments for attributes. Those attributes are to be exported in MAX Script. @@ -94,8 +99,7 @@ class ExtractTyCache(publish.Extractor): for operator in opt_list: if tycache_spline_enabled: export_mode = f'{operator}.exportMode=3' - has_tyc_spline = f'{operator}.tycacheSplines=true' - job_args.extend([export_mode, has_tyc_spline]) + job_args.append(export_mode) else: export_mode = f'{operator}.exportMode=2' job_args.append(export_mode) @@ -107,6 +111,11 @@ class ExtractTyCache(publish.Extractor): tycache_filename = f'{operator}.tyCacheFilename="{filepath}"' job_args.append(tycache_filename) # TODO: add the additional job args for tycache attributes + if additional_attributes: + additional_args = self.get_additional_attribute_args( + operator, additional_attributes + ) + job_args.extend(additional_args) tycache_export = f"{operator}.exportTyCache()" job_args.append(tycache_export) @@ -137,3 +146,24 @@ class ExtractTyCache(publish.Extractor): opt_list.append(opt) return opt_list + + def get_additional_attribute_args(self, operator, attrs): + """Get Additional args with the attributes pre-set by user + + Args: + operator (str): export particle operator + attrs (dict): a dict which stores the additional attributes + added by user + + Returns: + additional_args(list): a list of additional args for MAX script + """ + additional_args = [] + for key, value in attrs.items(): + tyc_attribute = None + if isinstance(value, bool): + tyc_attribute = f"{operator}.{key}=True" + elif isinstance(value, str): + tyc_attribute = f"{operator}.{key}={value}" + additional_args.append(tyc_attribute) + return additional_args From 3c2c33bcea84ada5c9292e12fc53c51e74a73e5a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 15 Sep 2023 17:06:29 +0100 Subject: [PATCH 0148/1224] 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 0149/1224] 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 0150/1224] 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 0151/1224] 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 0152/1224] 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 8e25677aa73e6034e8254c99921fac4e9d9a303f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 18 Sep 2023 15:48:47 +0800 Subject: [PATCH 0153/1224] finish the tycache attributes collector --- .../publish/collect_tycache_attributes.py | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index e312dd8826..122b0d6451 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -1,6 +1,6 @@ import pyblish.api -from openpype.lib import EnumDef +from openpype.lib import EnumDef, TextDef from openpype.pipeline.publish import OpenPypePyblishPluginMixin @@ -20,51 +20,54 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, if tycache_boolean_attributes: for attrs in tycache_boolean_attributes: all_tyc_attributes_dict[attrs] = True - self.log.debug(f"Found tycache attributes: {tycache_boolean_attributes}") + tyc_layer_attr = attr_values.get("tycache_layer") + if tyc_layer_attr: + all_tyc_attributes_dict["tycacheLayer"] = ( + tyc_layer_attr) + tyc_objname_attr = attr_values.get("tycache_objname") + if tyc_objname_attr: + all_tyc_attributes_dict["tycache_objname"] = ( + tyc_objname_attr) + self.log.debug( + f"Found tycache attributes: {all_tyc_attributes_dict}") @classmethod def get_attribute_defs(cls): - tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", "tycacheChanPos", - "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", - "tycacheChanSpin", "tycacheChanShape", "tycacheChanMatID", - "tycacheChanMapping", "tycacheChanMaterials", - "tycacheChanCustomFloat" + # TODO: Support the attributes with maxObject array + tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", + "tycacheChanPos", "tycacheChanRot", + "tycacheChanScale", "tycacheChanVel", + "tycacheChanSpin", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials", "tycacheChanCustomFloat" + "tycacheChanCustomVector", "tycacheChanCustomTM", + "tycacheChanPhysX", "tycacheMeshBackup", + "tycacheCreateObjectIfNotCreated", + "tycacheAdditionalCloth", + "tycacheAdditionalSkin", + "tycacheAdditionalSkinID", + "tycacheAdditionalSkinIDValue", + "tycacheAdditionalTerrain", + "tycacheAdditionalVDB", + "tycacheAdditionalSplinePaths", + "tycacheAdditionalGeo", + "tycacheAdditionalGeoActivateModifiers", + "tycacheSplines", + "tycacheSplinesAdditionalSplines" ] return [ EnumDef("all_tyc_attrs", tyc_attr_enum, default=None, - multiselection=True - - ) + multiselection=True, + label="TyCache Attributes"), + TextDef("tycache_layer", + label="TyCache Layer", + tooltip="Name of tycache layer", + default=""), + TextDef("tycache_objname", + label="TyCache Object Name", + tooltip="TyCache Object Name", + default="") ] -""" - - .tycacheChanCustomFloat : boolean - .tycacheChanCustomVector : boolean - .tycacheChanCustomTM : boolean - .tycacheChanPhysX : boolean - .tycacheMeshBackup : boolean - .tycacheCreateObject : boolean - .tycacheCreateObjectIfNotCreated : boolean - .tycacheLayer : string - .tycacheObjectName : string - .tycacheAdditionalCloth : boolean - .tycacheAdditionalSkin : boolean - .tycacheAdditionalSkinID : boolean - .tycacheAdditionalSkinIDValue : integer - .tycacheAdditionalTerrain : boolean - .tycacheAdditionalVDB : boolean - .tycacheAdditionalSplinePaths : boolean - .tycacheAdditionalTyMesher : boolean - .tycacheAdditionalGeo : boolean - .tycacheAdditionalObjectList_deprecated : node array - .tycacheAdditionalObjectList : maxObject array - .tycacheAdditionalGeoActivateModifiers : boolean - .tycacheSplines: boolean - .tycacheSplinesAdditionalSplines : boolean - .tycacheSplinesAdditionalSplinesObjectList_deprecated : node array - .tycacheSplinesAdditionalObjectList : maxObject array - -""" From d38cf8258954523f7025015670bfe6b7130c6d08 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 18 Sep 2023 08:49:51 +0100 Subject: [PATCH 0154/1224] Fix file path fetching from context. --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 2da5a6f1c2..2ea0b134d1 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -27,7 +27,7 @@ class AudioLoader(load.LoaderPlugin): start_frame = cmds.playbackOptions(query=True, min=True) sound_node = cmds.sound( - file=context["representation"]["data"]["path"], offset=start_frame + file=self.filepath_from_context(context), offset=start_frame ) cmds.timeControl( mel.eval("$gPlayBackSlider=$gPlayBackSlider"), From 7c1d81d62c267ad04c36f33c94ecb1aec3f569aa Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 18 Sep 2023 08:50:11 +0100 Subject: [PATCH 0155/1224] Improve loader label. --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 2ea0b134d1..ecf98303d2 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -18,7 +18,7 @@ class AudioLoader(load.LoaderPlugin): """Specific loader of audio.""" families = ["audio"] - label = "Import audio" + label = "Load audio" representations = ["wav"] icon = "volume-up" color = "orange" From ccf4b7f54781d81d9d6d331f63e3a5a8ab0e0d6f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 18 Sep 2023 10:52:38 +0100 Subject: [PATCH 0156/1224] 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 0157/1224] 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 0158/1224] 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 0159/1224] 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 0160/1224] 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 411f4bacd16d3d3c5c4ffb29d69f88f383b094dc Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 18 Sep 2023 15:03:45 +0100 Subject: [PATCH 0161/1224] Support new publisher for colorsets validation. --- .../maya/plugins/publish/validate_color_sets.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py index 766124cd9e..173fee4179 100644 --- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py @@ -3,9 +3,10 @@ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - RepairAction, ValidateMeshOrder, - OptionalPyblishPluginMixin + OptionalPyblishPluginMixin, + PublishValidationError, + RepairAction ) @@ -22,8 +23,9 @@ class ValidateColorSets(pyblish.api.Validator, hosts = ['maya'] families = ['model'] label = 'Mesh ColorSets' - actions = [openpype.hosts.maya.api.action.SelectInvalidAction, - RepairAction] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] optional = True @staticmethod @@ -48,8 +50,9 @@ class ValidateColorSets(pyblish.api.Validator, invalid = self.get_invalid(instance) if invalid: - raise ValueError("Meshes found with " - "Color Sets: {0}".format(invalid)) + raise PublishValidationError( + message="Meshes found with Color Sets: {0}".format(invalid) + ) @classmethod def repair(cls, instance): From c03326f5d8d0c45427d5cb24187c390d2b8c5113 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 19 Sep 2023 19:19:40 +0800 Subject: [PATCH 0162/1224] Jakub's comment on the review plugin --- openpype/hosts/nuke/api/plugin.py | 5 + .../publish/extract_review_data_mov.py | 2 +- openpype/settings/ayon_settings.py | 21 ++- .../defaults/project_settings/nuke.json | 54 +++++++ .../schemas/schema_nuke_publish.json | 145 ++++++++++++++++++ .../nuke/server/settings/publish_plugins.py | 10 +- .../settings_project_global.md | 12 +- website/docs/pype2/admin_presets_plugins.md | 3 +- 8 files changed, 236 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index a0e1525cd0..adbe43e481 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -21,6 +21,9 @@ from openpype.pipeline import ( CreatedInstance, get_current_task_name ) +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS +) from .lib import ( INSTANCE_DATA_KNOB, Knobby, @@ -801,6 +804,8 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") self.file = self.fhead + self.name + ".{}".format(self.ext) + if self.ext != VIDEO_EXTENSIONS: + self.file = os.path.basename(self.path_in) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 956d1a54a3..1568a2de9b 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -8,7 +8,7 @@ from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataMov(publish.Extractor): +class ExtractReviewDataBakingStreams(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 9a4f0607e0..b7fcaa1216 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -748,7 +748,19 @@ def _convert_nuke_project_settings(ayon_settings, output): ) new_review_data_outputs = {} - for item in ayon_publish["ExtractReviewDataMov"]["outputs"]: + outputs_settings = None + # just in case that the users having old presets in outputs setting + deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] + current_review_settings = ( + ayon_publish["ExtractReviewDataBakingStreams"] + ) + if deprecrated_review_settings["outputs"] == ( + current_review_settings["outputs"]): + outputs_settings = current_review_settings["outputs"] + else: + outputs_settings = deprecrated_review_settings["outputs"] + + for item in outputs_settings: item_filter = item["filter"] if "product_names" in item_filter: item_filter["subsets"] = item_filter.pop("product_names") @@ -767,7 +779,12 @@ def _convert_nuke_project_settings(ayon_settings, output): name = item.pop("name") new_review_data_outputs[name] = item - ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs + + if deprecrated_review_settings["outputs"] == ( + current_review_settings["outputs"]): + current_review_settings["outputs"] = new_review_data_outputs + else: + deprecrated_review_settings["outputs"] = new_review_data_outputs collect_instance_data = ayon_publish["CollectInstanceData"] if "sync_workfile_version_on_product_types" in collect_instance_data: diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 7961e77113..fac78dbcd5 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -501,6 +501,60 @@ } } }, + "ExtractReviewDataBakingStreams": { + "enabled": true, + "viewer_lut_raw": false, + "outputs": { + "baking": { + "filter": { + "task_types": [], + "families": [], + "subsets": [] + }, + "read_raw": false, + "viewer_process_override": "", + "bake_viewer_process": true, + "bake_viewer_input_process": true, + "reformat_nodes_config": { + "enabled": false, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "value": "to format" + }, + { + "type": "text", + "name": "format", + "value": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + } + }, "ExtractSlateFrame": { "viewer_lut_raw": false, "key_value_mapping": { 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 f006392bef..0f366d55ba 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 @@ -371,6 +371,151 @@ ] }, + { + "type": "label", + "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewDataBakingStreams instead." + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ExtractReviewDataBakingStreams", + "label": "ExtractReviewDataBakingStreams", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "viewer_lut_raw", + "label": "Viewer LUT raw" + }, + { + "key": "outputs", + "label": "Output Definitions", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "dict", + "collapsible": false, + "key": "filter", + "label": "Filtering", + "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" + } + ] + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "read_raw", + "label": "Read colorspace RAW", + "default": false + }, + { + "type": "text", + "key": "viewer_process_override", + "label": "Viewer Process colorspace profile override" + }, + { + "type": "boolean", + "key": "bake_viewer_process", + "label": "Bake Viewer Process" + }, + { + "type": "boolean", + "key": "bake_viewer_input_process", + "label": "Bake Viewer Input Process (LUTs)" + }, + { + "type": "separator" + }, + { + "key": "reformat_nodes_config", + "type": "dict", + "label": "Reformat Nodes", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Reposition knobs supported only.
You can add multiple reformat nodes
and set their knobs. Order of reformat
nodes is important. First reformat node
will be applied first and last reformat
node will be applied last." + }, + { + "key": "reposition_nodes", + "type": "list", + "label": "Reposition nodes", + "object_type": { + "type": "dict", + "children": [ + { + "key": "node_class", + "label": "Node class", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "extension", + "label": "Write node file type" + }, + { + "key": "add_custom_tags", + "label": "Add custom tags", + "type": "list", + "object_type": "text" + } + ] + } + } + + ] + }, { "type": "dict", "collapsible": true, diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index c78685534f..423448219d 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -165,7 +165,7 @@ class BakingStreamModel(BaseSettingsModel): title="Custom tags", default_factory=list) -class ExtractReviewDataMovModel(BaseSettingsModel): +class ExtractReviewBakingStreamsModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") outputs: list[BakingStreamModel] = Field( @@ -266,9 +266,9 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Lut", default_factory=ExtractReviewDataLutModel ) - ExtractReviewDataMov: ExtractReviewDataMovModel = Field( - title="Extract Review Data Mov", - default_factory=ExtractReviewDataMovModel + ExtractReviewDataBakingStreams: ExtractReviewBakingStreamsModel = Field( + title="Extract Review Data Baking Streams", + default_factory=ExtractReviewBakingStreamsModel ) ExtractSlateFrame: ExtractSlateFrameModel = Field( title="Extract Slate Frame", @@ -410,7 +410,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "ExtractReviewDataLut": { "enabled": False }, - "ExtractReviewDataMov": { + "ExtractReviewDataBakingStreams": { "enabled": True, "viewer_lut_raw": False, "outputs": [ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 5ddf247d98..9092ccdcdf 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -189,10 +189,10 @@ A profile may generate multiple outputs from a single input. Each output must de - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). - - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataBakingStreams/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. - + ### Extract Burnin Plugin is responsible for adding burnins into review representations. @@ -226,13 +226,13 @@ A burnin profile may set multiple burnin outputs from one input. The burnin's na | **Bottom Centered** | Bottom center content. | str | "{username}" | | **Bottom Right** | Bottom right corner content. | str | "{frame_start}-{current_frame}-{frame_end}" | -Each burnin profile can be configured with additional family filtering and can -add additional tags to the burnin representation, these can be configured under +Each burnin profile can be configured with additional family filtering and can +add additional tags to the burnin representation, these can be configured under the profile's **Additional filtering** section. :::note Filename suffix -The filename suffix is appended to filename of the source representation. For -example, if the source representation has suffix **"h264"** and the burnin +The filename suffix is appended to filename of the source representation. For +example, if the source representation has suffix **"h264"** and the burnin suffix is **"client"** then the final suffix is **"h264_client"**. ::: diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index 6a057f4bb4..a869ead819 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -534,8 +534,7 @@ Plugin responsible for generating thumbnails with colorspace controlled by Nuke. } ``` -### `ExtractReviewDataMov` - +### `ExtractReviewDataBakingStreams` `viewer_lut_raw` **true** will publish the baked mov file without any colorspace conversion. It will be baked with the workfile workspace. This can happen in case the Viewer input process uses baked screen space luts. #### baking with controlled colorspace From e0fba9713d37c4cf73210c2c37179f9403b2399a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 19 Sep 2023 19:22:52 +0800 Subject: [PATCH 0163/1224] hound --- openpype/settings/ayon_settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index b7fcaa1216..0b72d267f7 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -755,7 +755,8 @@ def _convert_nuke_project_settings(ayon_settings, output): ayon_publish["ExtractReviewDataBakingStreams"] ) if deprecrated_review_settings["outputs"] == ( - current_review_settings["outputs"]): + current_review_settings["outputs"] + ): outputs_settings = current_review_settings["outputs"] else: outputs_settings = deprecrated_review_settings["outputs"] @@ -781,7 +782,8 @@ def _convert_nuke_project_settings(ayon_settings, output): new_review_data_outputs[name] = item if deprecrated_review_settings["outputs"] == ( - current_review_settings["outputs"]): + current_review_settings["outputs"] + ): current_review_settings["outputs"] = new_review_data_outputs else: deprecrated_review_settings["outputs"] = new_review_data_outputs From 5b2e9da304697aea73af8cac3616d895e4bdd0d0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 19 Sep 2023 14:52:19 +0200 Subject: [PATCH 0164/1224] (nuke): fix set colorspace on writes --- openpype/hosts/nuke/api/lib.py | 48 +++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 41e6a27cef..8626151beb 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2320,23 +2320,51 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. # get data from avalon knob avalon_knob_data = read_avalon_data(node) + node_data = get_node_data(node, INSTANCE_DATA_KNOB) if avalon_knob_data.get("id") != "pyblish.avalon.instance": + + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and avalon_knob_data.get("id") != "pyblish.avalon.instance" + ): + continue + elif ( + node_data + and node_data.get("id") != "pyblish.avalon.instance" + ): continue - if "creator" not in avalon_knob_data: + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and "creator" not in avalon_knob_data + ): + continue + elif ( + node_data + and "creator_identifier" not in node_data + ): continue - # establish families - families = [avalon_knob_data["family"]] - if avalon_knob_data.get("families"): - families.append(avalon_knob_data.get("families")) - nuke_imageio_writes = get_imageio_node_setting( - node_class=avalon_knob_data["families"], - plugin_name=avalon_knob_data["creator"], - subset=avalon_knob_data["subset"] - ) + nuke_imageio_writes = None + if avalon_knob_data: + # establish families + families = [avalon_knob_data["family"]] + if avalon_knob_data.get("families"): + families.append(avalon_knob_data.get("families")) + + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["families"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) + elif node_data: + nuke_imageio_writes = get_write_node_template_attr(node) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) From 961013e9afd5074b768ee73fe0a10464bdb62cd0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 19 Sep 2023 15:03:18 +0200 Subject: [PATCH 0165/1224] Nuke: adding print of name of node which is processed --- openpype/hosts/nuke/api/lib.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 8626151beb..fb2b5d0f45 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2316,14 +2316,13 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. ''' Adds correct colorspace to write node dict ''' - for node in nuke.allNodes(filter="Group"): + for node in nuke.allNodes(filter="Group", group=self._root_node): + log.info("Setting colorspace to `{}`".format(node.name())) # get data from avalon knob avalon_knob_data = read_avalon_data(node) node_data = get_node_data(node, INSTANCE_DATA_KNOB) - if avalon_knob_data.get("id") != "pyblish.avalon.instance": - if ( # backward compatibility # TODO: remove this once old avalon data api will be removed @@ -2350,7 +2349,6 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. ): continue - nuke_imageio_writes = None if avalon_knob_data: # establish families From 3d7479b65ec4a5b862633536791ac0773d5cdd67 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 19 Sep 2023 15:43:26 +0200 Subject: [PATCH 0166/1224] nuke: extract review data mov read node with expression --- 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 a0e1525cd0..1e318e17cf 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -869,6 +869,11 @@ class ExporterReviewMov(ExporterReview): r_node["origlast"].setValue(self.last_frame) r_node["colorspace"].setValue(self.write_colorspace) + # do not rely on defaults, set explicitly + # to be sure it is set correctly + r_node["frame_mode"].setValue("expression") + r_node["frame"].setValue("") + if read_raw: r_node["raw"].setValue(1) From b7995a41a71fc8d0e9593f7c9940a8e7f06de9dc Mon Sep 17 00:00:00 2001 From: Kayla Date: Wed, 20 Sep 2023 21:26:02 +0800 Subject: [PATCH 0167/1224] 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 bd049da6f93fdffac69d673a66d9387351471576 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 20 Sep 2023 16:11:13 +0200 Subject: [PATCH 0168/1224] Update openpype/hosts/fusion/plugins/load/load_usd.py --- openpype/hosts/fusion/plugins/load/load_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index 8c2c69f52f..f12fbd5ed0 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -9,7 +9,7 @@ from openpype.hosts.fusion.api import ( ) -class FusionLoadAlembicMesh(load.LoaderPlugin): +class FusionLoadUSD(load.LoaderPlugin): """Load USD into Fusion Support for USD was added since Fusion 18.5 From 02a1602e30c0f6b27237c4fa5ad7f9bcd7238631 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 21 Sep 2023 18:57:34 +0800 Subject: [PATCH 0169/1224] 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 0170/1224] 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 0171/1224] 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 b8e054a50cac66c4e671da0261093be68db555b2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:01:07 +0200 Subject: [PATCH 0172/1224] renaming variable to make more sense --- openpype/pipeline/colorspace.py | 12 ++++++------ tests/unit/openpype/pipeline/test_colorspace.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index ae16c13635..454c23a55e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -24,7 +24,7 @@ log = Logger.get_logger(__name__) class CachedData: remapping = None - python3compatible = None + has_compatible_ocio_package = None config_version_data = None ocio_config_colorspaces = {} @@ -459,17 +459,17 @@ def _get_wrapped_with_subprocess(command_group, command, **kwargs): # TODO: this should be part of ocio_wrapper.py def compatibility_check(): """Making sure PyOpenColorIO is importable""" - if CachedData.python3compatible is not None: - return CachedData.python3compatible + if CachedData.has_compatible_ocio_package is not None: + return CachedData.has_compatible_ocio_package try: import PyOpenColorIO # noqa: F401 - CachedData.python3compatible = True + CachedData.has_compatible_ocio_package = True except ImportError: - CachedData.python3compatible = False + CachedData.has_compatible_ocio_package = False # compatible - return CachedData.python3compatible + return CachedData.has_compatible_ocio_package # TODO: this should be part of ocio_wrapper.py diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index 435ea709ab..493be786a3 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -227,7 +227,7 @@ class TestPipelineColorspace(TestPipeline): expected_hiero = "Gamma 2.2 Rec.709 - Texture" # switch to python 2 compatibility mode - colorspace.CachedData.python3compatible = False + colorspace.CachedData.has_compatible_ocio_package = False nuke_colorspace = colorspace.get_colorspace_name_from_filepath( nuke_filepath, From da7ffb84fba2041f7415537e5031485f5835f8e8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:10:27 +0200 Subject: [PATCH 0173/1224] improving regex patter for underscored pattern --- openpype/pipeline/colorspace.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 454c23a55e..677656c02f 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -341,7 +341,7 @@ def parse_colorspace_from_filepath( pattern = "|".join( # Allow to match spaces also as underscores because the # integrator replaces spaces with underscores in filenames - re.escape(colorspace).replace(r"\ ", r"[_ ]") for colorspace in + re.escape(colorspace) for colorspace in # Sort by longest first so the regex matches longer matches # over smaller matches, e.g. matching 'Output - sRGB' over 'sRGB' sorted(colorspaces, key=len, reverse=True) @@ -355,22 +355,20 @@ def parse_colorspace_from_filepath( colorspace_name = None colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) - underscored_colorspaces = { - key.replace(" ", "_"): key for key in colorspaces + underscored_colorspaces = list({ + key.replace(" ", "_") for key in colorspaces if " " in key - } + }) # match colorspace from filepath - regex_pattern = _get_colorspace_match_regex(colorspaces) + regex_pattern = _get_colorspace_match_regex( + colorspaces + underscored_colorspaces) match = regex_pattern.search(filepath) colorspace = match.group(0) if match else None if colorspace: colorspace_name = colorspace - if colorspace in underscored_colorspaces: - colorspace_name = underscored_colorspaces[colorspace] - if not colorspace_name: log.info("No matching colorspace in config '{}' for path: '{}'".format( config_path, filepath From 6e7cde73be7c3309deb78f10324d8779e354f5cc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:17:45 +0200 Subject: [PATCH 0174/1224] second part of previous commit --- openpype/pipeline/colorspace.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 677656c02f..1bb4624537 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -353,29 +353,28 @@ def parse_colorspace_from_filepath( "Must provide `config_path` if `colorspaces` is not provided." ) - colorspace_name = None colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) - underscored_colorspaces = list({ - key.replace(" ", "_") for key in colorspaces + underscored_colorspaces = { + key.replace(" ", "_"): key for key in colorspaces if " " in key - }) + } # match colorspace from filepath regex_pattern = _get_colorspace_match_regex( - colorspaces + underscored_colorspaces) + colorspaces + underscored_colorspaces.keys()) match = regex_pattern.search(filepath) colorspace = match.group(0) if match else None if colorspace: - colorspace_name = colorspace + return colorspace - if not colorspace_name: - log.info("No matching colorspace in config '{}' for path: '{}'".format( - config_path, filepath - )) - return None + if colorspace in underscored_colorspaces: + return underscored_colorspaces[colorspace] - return colorspace_name + log.info("No matching colorspace in config '{}' for path: '{}'".format( + config_path, filepath + )) + return None def validate_imageio_colorspace_in_config(config_path, colorspace_name): From d045b8322304dbcbf86e63d7c767561c5f7cd800 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:20:57 +0200 Subject: [PATCH 0175/1224] reversing order for underscored first --- openpype/pipeline/colorspace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 1bb4624537..4ece96cfff 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -365,12 +365,12 @@ def parse_colorspace_from_filepath( match = regex_pattern.search(filepath) colorspace = match.group(0) if match else None - if colorspace: - return colorspace - if colorspace in underscored_colorspaces: return underscored_colorspaces[colorspace] + if colorspace: + return colorspace + log.info("No matching colorspace in config '{}' for path: '{}'".format( config_path, filepath )) From 49cfebb1639389447d0e4973b0f1d42630beec7d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:23:34 +0200 Subject: [PATCH 0176/1224] log can be added as explicit arg --- openpype/pipeline/colorspace.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 4ece96cfff..a1e86dbd64 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -442,11 +442,7 @@ def _get_wrapped_with_subprocess(command_group, command, **kwargs): log.info("Executing: {}".format(" ".join(args))) - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) + run_openpype_process(*args, logger=log) # return all colorspaces with open(tmp_json_path, "r") as f_: From aefbc7ef47e46ff91cd85df045801ad7ad6349bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:24:36 +0200 Subject: [PATCH 0177/1224] removing extra space --- openpype/pipeline/colorspace.py | 8 ++++---- tests/unit/openpype/pipeline/test_colorspace.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a1e86dbd64..0cc2f35a49 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -24,7 +24,7 @@ log = Logger.get_logger(__name__) class CachedData: remapping = None - has_compatible_ocio_package = None + has_compatible_ocio_package = None config_version_data = None ocio_config_colorspaces = {} @@ -452,14 +452,14 @@ def _get_wrapped_with_subprocess(command_group, command, **kwargs): # TODO: this should be part of ocio_wrapper.py def compatibility_check(): """Making sure PyOpenColorIO is importable""" - if CachedData.has_compatible_ocio_package is not None: + if CachedData.has_compatible_ocio_package is not None: return CachedData.has_compatible_ocio_package try: import PyOpenColorIO # noqa: F401 - CachedData.has_compatible_ocio_package = True + CachedData.has_compatible_ocio_package = True except ImportError: - CachedData.has_compatible_ocio_package = False + CachedData.has_compatible_ocio_package = False # compatible return CachedData.has_compatible_ocio_package diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index 493be786a3..85faa8ff5d 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -227,7 +227,7 @@ class TestPipelineColorspace(TestPipeline): expected_hiero = "Gamma 2.2 Rec.709 - Texture" # switch to python 2 compatibility mode - colorspace.CachedData.has_compatible_ocio_package = False + colorspace.CachedData.has_compatible_ocio_package = False nuke_colorspace = colorspace.get_colorspace_name_from_filepath( nuke_filepath, From 89d3a3ae228c8ed4f8a1e9b7e5e6cea2c6b65539 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:28:27 +0200 Subject: [PATCH 0178/1224] caching per config path compatibility check --- openpype/pipeline/colorspace.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 0cc2f35a49..446849b76e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -25,7 +25,7 @@ log = Logger.get_logger(__name__) class CachedData: remapping = None has_compatible_ocio_package = None - config_version_data = None + config_version_data = {} ocio_config_colorspaces = {} @@ -469,26 +469,26 @@ def compatibility_check(): def compatibility_check_config_version(config_path, major=1, minor=None): """Making sure PyOpenColorIO config version is compatible""" - if not CachedData.config_version_data: + if not CachedData.config_version_data.get(config_path): if compatibility_check(): # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_version_data - CachedData.config_version_data = _get_version_data(config_path) + CachedData.config_version_data[config_path] = _get_version_data(config_path) else: # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - CachedData.config_version_data = _get_wrapped_with_subprocess( + CachedData.config_version_data[config_path] = _get_wrapped_with_subprocess( "config", "get_version", config_path=config_path ) # check major version - if CachedData.config_version_data["major"] != major: + if CachedData.config_version_data[config_path]["major"] != major: return False # check minor version - if minor and CachedData.config_version_data["minor"] != minor: + if minor and CachedData.config_version_data[config_path]["minor"] != minor: return False # compatible From 2f89cadd8a82fb1590a2bd064c24ddc27e42e893 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:29:54 +0200 Subject: [PATCH 0179/1224] hound --- openpype/pipeline/colorspace.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 446849b76e..2dd618a1f2 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -474,14 +474,16 @@ def compatibility_check_config_version(config_path, major=1, minor=None): # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_version_data - CachedData.config_version_data[config_path] = _get_version_data(config_path) + CachedData.config_version_data[config_path] = \ + _get_version_data(config_path) else: # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - CachedData.config_version_data[config_path] = _get_wrapped_with_subprocess( - "config", "get_version", config_path=config_path - ) + CachedData.config_version_data[config_path] = \ + _get_wrapped_with_subprocess( + "config", "get_version", config_path=config_path + ) # check major version if CachedData.config_version_data[config_path]["major"] != major: From da1d62f8931d38b2cae119e655575b67db69dba7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Sep 2023 17:31:21 +0200 Subject: [PATCH 0180/1224] hound --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 2dd618a1f2..44cff34c67 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -483,7 +483,7 @@ def compatibility_check_config_version(config_path, major=1, minor=None): CachedData.config_version_data[config_path] = \ _get_wrapped_with_subprocess( "config", "get_version", config_path=config_path - ) + ) # check major version if CachedData.config_version_data[config_path]["major"] != major: From 174ef45b0b068ccfc9382a254af64ea0d4c5b009 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Sep 2023 15:07:58 +0800 Subject: [PATCH 0181/1224] jakub's comment on apply_settings and fix the bug of not being extracted the review --- openpype/hosts/nuke/api/plugin.py | 11 ++- ...ov.py => extract_review_baking_streams.py} | 30 +++++++- .../nuke/server/settings/publish_plugins.py | 71 +++++++++++++++++++ server_addon/nuke/server/version.py | 2 +- 4 files changed, 109 insertions(+), 5 deletions(-) rename openpype/hosts/nuke/plugins/publish/{extract_review_data_mov.py => extract_review_baking_streams.py} (82%) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index adbe43e481..a814615164 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -804,8 +804,13 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") self.file = self.fhead + self.name + ".{}".format(self.ext) - if self.ext != VIDEO_EXTENSIONS: - self.file = os.path.basename(self.path_in) + if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: + filename = os.path.basename(self.path_in) + self.file = filename + if ".{}".format(self.ext) not in self.file: + wrg_ext = filename.split(".")[-1] + self.file = filename.replace(wrg_ext, self.ext) + self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") @@ -926,7 +931,7 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Path: {}".format(self.path)) write_node["file"].setValue(str(self.path)) write_node["file_type"].setValue(str(self.ext)) - + self.log.debug("{0}".format(self.ext)) # Knobs `meta_codec` and `mov64_codec` are not available on centos. # TODO shouldn't this come from settings on outputs? try: diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py similarity index 82% rename from openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py rename to openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py index 1568a2de9b..59a3f659c9 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py @@ -16,7 +16,7 @@ class ExtractReviewDataBakingStreams(publish.Extractor): """ order = pyblish.api.ExtractorOrder + 0.01 - label = "Extract Review Data Mov" + label = "Extract Review Data Baking Streams" families = ["review"] hosts = ["nuke"] @@ -25,6 +25,34 @@ class ExtractReviewDataBakingStreams(publish.Extractor): viewer_lut_raw = None outputs = {} + @classmethod + def apply_settings(cls, project_settings): + """just in case there are some old presets + in deprecrated ExtractReviewDataMov Plugins + """ + nuke_publish = project_settings["nuke"]["publish"] + deprecrated_review_settings = nuke_publish["ExtractReviewDataMov"] + current_review_settings = ( + nuke_publish["ExtractReviewDataBakingStreams"] + ) + if deprecrated_review_settings["viewer_lut_raw"] == ( + current_review_settings["viewer_lut_raw"] + ): + cls.viewer_lut_raw = ( + current_review_settings["viewer_lut_raw"] + ) + else: + cls.viewer_lut_raw = ( + deprecrated_review_settings["viewer_lut_raw"] + ) + + if deprecrated_review_settings["outputs"] == ( + current_review_settings["outputs"] + ): + cls.outputs = current_review_settings["outputs"] + else: + cls.outputs = deprecrated_review_settings["outputs"] + def process(self, instance): families = set(instance.data["families"]) diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 423448219d..6459dd7225 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -165,6 +165,18 @@ class BakingStreamModel(BaseSettingsModel): title="Custom tags", default_factory=list) +class ExtractReviewDataMovModel(BaseSettingsModel): + """[deprecated] use Extract Review Data Baking + Streams instead. + """ + enabled: bool = Field(title="Enabled") + viewer_lut_raw: bool = Field(title="Viewer lut raw") + outputs: list[BakingStreamModel] = Field( + default_factory=list, + title="Baking streams" + ) + + class ExtractReviewBakingStreamsModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") @@ -266,6 +278,10 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Lut", default_factory=ExtractReviewDataLutModel ) + ExtractReviewDataMov: ExtractReviewDataMovModel = Field( + title="Extract Review Data Mov", + default_factory=ExtractReviewDataMovModel + ) ExtractReviewDataBakingStreams: ExtractReviewBakingStreamsModel = Field( title="Extract Review Data Baking Streams", default_factory=ExtractReviewBakingStreamsModel @@ -410,6 +426,61 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "ExtractReviewDataLut": { "enabled": False }, + "ExtractReviewDataMov": { + "enabled": True, + "viewer_lut_raw": False, + "outputs": [ + { + "name": "baking", + "filter": { + "task_types": [], + "product_types": [], + "product_names": [] + }, + "read_raw": False, + "viewer_process_override": "", + "bake_viewer_process": True, + "bake_viewer_input_process": True, + "reformat_nodes_config": { + "enabled": False, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "text": "to format" + }, + { + "type": "text", + "name": "format", + "text": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "text": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "boolean": True + }, + { + "type": "bool", + "name": "pbb", + "boolean": False + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + ] + }, "ExtractReviewDataBakingStreams": { "enabled": True, "viewer_lut_raw": False, diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 3cf203e46580d88c842bd931177ebdb159690f89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 10:48:31 +0200 Subject: [PATCH 0182/1224] AYON settings: Extract OIIO transcode settings (#5639) * added name to ExtractOIIOTranscode output definition * convert outputs of 'ExtractOIIOTranscode' to 'dict' --- openpype/settings/ayon_settings.py | 23 ++++++++++++++++++- .../core/server/settings/publish_plugins.py | 7 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 9a4f0607e0..3be8ac8ae5 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1102,7 +1102,7 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): "studio_name", "studio_code", ): - ayon_core.pop(key) + ayon_core.pop(key, None) # Publish conversion ayon_publish = ayon_core["publish"] @@ -1140,6 +1140,27 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): profile["outputs"] = new_outputs + # ExtractOIIOTranscode plugin + extract_oiio_transcode = ayon_publish["ExtractOIIOTranscode"] + extract_oiio_transcode_profiles = extract_oiio_transcode["profiles"] + for profile in extract_oiio_transcode_profiles: + new_outputs = {} + name_counter = {} + for output in profile["outputs"]: + if "name" in output: + name = output.pop("name") + else: + # Backwards compatibility for setting without 'name' in model + name = 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 + profile["outputs"] = new_outputs + # Extract Burnin plugin extract_burnin = ayon_publish["ExtractBurnin"] extract_burnin_options = extract_burnin["options"] diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index c012312579..69a759465e 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -116,6 +116,8 @@ class OIIOToolArgumentsModel(BaseSettingsModel): class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): + _layout = "expanded" + name: str = Field("", title="Name") extension: str = Field("", title="Extension") transcoding_type: str = Field( "colorspace", @@ -164,6 +166,11 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): title="Output Definitions", ) + @validator("outputs") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + class ExtractOIIOTranscodeModel(BaseSettingsModel): enabled: bool = Field(True) From 4f52c70093217d79ad542e88a6078b5bb3be7df6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 22 Sep 2023 12:06:37 +0300 Subject: [PATCH 0183/1224] make self publish button optional --- openpype/hosts/houdini/api/plugin.py | 11 ++++++++++- .../defaults/project_settings/houdini.json | 3 +++ .../projects_schema/schema_project_houdini.json | 4 ++++ .../schemas/schema_houdini_general.json | 14 ++++++++++++++ server_addon/houdini/server/settings/general.py | 15 +++++++++++++++ server_addon/houdini/server/settings/main.py | 9 +++++++++ server_addon/houdini/server/version.py | 2 +- 7 files changed, 56 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/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 756b33f7f7..8670103a81 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -168,6 +168,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] settings_name = None + _add_self_publish_button = False def create(self, subset_name, instance_data, pre_create_data): try: @@ -194,7 +195,10 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self) self._add_instance_to_context(instance) imprint(instance_node, instance.data_to_store()) - add_self_publish_button(instance_node) + + if self._add_self_publish_button: + add_self_publish_button(instance_node) + return instance except hou.Error as er: @@ -300,6 +304,11 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" + # Apply General Settings + self._add_self_publish_button = \ + project_settings["houdini"]["general"]["add_self_publish_button"] + + # Apply Creator Settings settings_name = self.settings_name if settings_name is None: settings_name = self.__class__.__name__ diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 5392fc34dd..f60f5f2761 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,4 +1,7 @@ { + "general": { + "add_self_publish_button": false + }, "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..a69501c98c --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -0,0 +1,14 @@ +{ + "type": "dict", + "key": "general", + "label": "General", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "add_self_publish_button", + "label": "Add Self Publish Button" + } + ] +} diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py new file mode 100644 index 0000000000..9c19acd6c9 --- /dev/null +++ b/server_addon/houdini/server/settings/general.py @@ -0,0 +1,15 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + + +class GeneralSettingsModel(BaseSettingsModel): + add_self_publish_button: bool = Field( + False, + title="Add Self Publish Button" + ) + + +DEFAULT_GENERAL_SETTINGS = { + "add_self_publish_button": False +} diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index fdb6838f5c..8de8d8aeae 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -5,6 +5,10 @@ from ayon_server.settings import ( MultiplatformPathListModel, ) +from .general import ( + GeneralSettingsModel, + DEFAULT_GENERAL_SETTINGS +) from .imageio import HoudiniImageIOModel from .publish_plugins import ( PublishPluginsModel, @@ -52,6 +56,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 +81,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 792e92ca44a22ad4318bc8ba330024719b86e068 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 22 Sep 2023 12:08:58 +0300 Subject: [PATCH 0184/1224] resolve hound --- server_addon/houdini/server/settings/general.py | 1 - server_addon/houdini/server/settings/main.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py index 9c19acd6c9..ce20a30e7c 100644 --- a/server_addon/houdini/server/settings/general.py +++ b/server_addon/houdini/server/settings/general.py @@ -2,7 +2,6 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel - class GeneralSettingsModel(BaseSettingsModel): add_self_publish_button: bool = Field( False, diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index 8de8d8aeae..1a3968bf28 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -81,7 +81,7 @@ class HoudiniSettings(BaseSettingsModel): DEFAULT_VALUES = { - "general" : DEFAULT_GENERAL_SETTINGS, + "general": DEFAULT_GENERAL_SETTINGS, "shelves": [], "create": DEFAULT_HOUDINI_CREATE_SETTINGS, "publish": DEFAULT_HOUDINI_PUBLISH_SETTINGS From 87ed2f960daa97d4d94b73403c5076519bf6b20c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:47:39 +0200 Subject: [PATCH 0185/1224] Launcher tool: Refactor launcher tool (for AYON) (#5612) * added helper classes to utils * implemented base of ayon utils * initial commit for launcher tool * use image for extender * actions are shown and can be triggered * fix actions on finished refresh * refresh automatically * fix re-refreshing of projects model * added page slide animation * updated abstrack classes * change how icon is prepared * fix actions sorting * show messages like in launcher tool * do not clear items on refresh * stop refresh timer only on close event * use Ynput/AYON for local settings json * register default actions in launcher action module * change register naming * move 'SquareButton' to utils widgets * removed duplicated method * removed unused variable * removed unused import * don't use lambda * swap default name for 'OpenPypeSettingsRegistry' * Change support version --- openpype/lib/local_settings.py | 14 +- openpype/modules/launcher_action.py | 73 ++- openpype/pipeline/actions.py | 8 +- openpype/tools/ayon_launcher/abstract.py | 297 ++++++++++ openpype/tools/ayon_launcher/control.py | 149 ++++++ .../tools/ayon_launcher/models/__init__.py | 8 + .../tools/ayon_launcher/models/actions.py | 505 ++++++++++++++++++ .../tools/ayon_launcher/models/selection.py | 72 +++ openpype/tools/ayon_launcher/ui/__init__.py | 6 + .../tools/ayon_launcher/ui/actions_widget.py | 453 ++++++++++++++++ .../tools/ayon_launcher/ui/hierarchy_page.py | 102 ++++ .../tools/ayon_launcher/ui/projects_widget.py | 135 +++++ .../ayon_launcher/ui/resources/__init__.py | 7 + .../ayon_launcher/ui/resources/options.png | Bin 0 -> 1772 bytes openpype/tools/ayon_launcher/ui/window.py | 295 ++++++++++ openpype/tools/ayon_utils/models/__init__.py | 29 + openpype/tools/ayon_utils/models/cache.py | 196 +++++++ openpype/tools/ayon_utils/models/hierarchy.py | 340 ++++++++++++ openpype/tools/ayon_utils/models/projects.py | 145 +++++ openpype/tools/ayon_utils/widgets/__init__.py | 37 ++ .../ayon_utils/widgets/folders_widget.py | 364 +++++++++++++ .../ayon_utils/widgets/projects_widget.py | 325 +++++++++++ .../tools/ayon_utils/widgets/tasks_widget.py | 436 +++++++++++++++ openpype/tools/ayon_utils/widgets/utils.py | 98 ++++ openpype/tools/launcher/actions.py | 44 +- openpype/tools/utils/__init__.py | 9 + openpype/tools/utils/widgets.py | 79 ++- 27 files changed, 4158 insertions(+), 68 deletions(-) create mode 100644 openpype/tools/ayon_launcher/abstract.py create mode 100644 openpype/tools/ayon_launcher/control.py create mode 100644 openpype/tools/ayon_launcher/models/__init__.py create mode 100644 openpype/tools/ayon_launcher/models/actions.py create mode 100644 openpype/tools/ayon_launcher/models/selection.py create mode 100644 openpype/tools/ayon_launcher/ui/__init__.py create mode 100644 openpype/tools/ayon_launcher/ui/actions_widget.py create mode 100644 openpype/tools/ayon_launcher/ui/hierarchy_page.py create mode 100644 openpype/tools/ayon_launcher/ui/projects_widget.py create mode 100644 openpype/tools/ayon_launcher/ui/resources/__init__.py create mode 100644 openpype/tools/ayon_launcher/ui/resources/options.png create mode 100644 openpype/tools/ayon_launcher/ui/window.py create mode 100644 openpype/tools/ayon_utils/models/__init__.py create mode 100644 openpype/tools/ayon_utils/models/cache.py create mode 100644 openpype/tools/ayon_utils/models/hierarchy.py create mode 100644 openpype/tools/ayon_utils/models/projects.py create mode 100644 openpype/tools/ayon_utils/widgets/__init__.py create mode 100644 openpype/tools/ayon_utils/widgets/folders_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/projects_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/tasks_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/utils.py diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 3fb35a7e7b..dae6e074af 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -494,10 +494,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): """ def __init__(self, name=None): - self.vendor = "pypeclub" - self.product = "openpype" + if AYON_SERVER_ENABLED: + vendor = "Ynput" + product = "AYON" + default_name = "AYON_settings" + else: + vendor = "pypeclub" + product = "openpype" + default_name = "openpype_settings" + self.vendor = vendor + self.product = product if not name: - name = "openpype_settings" + name = default_name path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index c4331b6094..5e14f25f76 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -1,3 +1,6 @@ +import os + +from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED from openpype.modules import ( OpenPypeModule, ITrayAction, @@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction): self.enabled = True # Tray attributes - self.window = None + self._window = None def tray_init(self): - self.create_window() + self._create_window() - self.add_doubleclick_callback(self.show_launcher) + self.add_doubleclick_callback(self._show_launcher) def tray_start(self): return def connect_with_modules(self, enabled_modules): # Register actions - if self.tray_initialized: - from openpype.tools.launcher import actions - actions.register_config_actions() - actions_paths = self.manager.collect_plugin_paths()["actions"] - actions.register_actions_from_paths(actions_paths) - actions.register_environment_actions() - - def create_window(self): - if self.window: + if not self.tray_initialized: return - from openpype.tools.launcher import LauncherWindow - self.window = LauncherWindow() + + from openpype.pipeline.actions import register_launcher_action_path + + actions_dir = os.path.join(PLUGINS_DIR, "actions") + if os.path.exists(actions_dir): + register_launcher_action_path(actions_dir) + + actions_paths = self.manager.collect_plugin_paths()["actions"] + for path in actions_paths: + if path and os.path.exists(path): + register_launcher_action_path(actions_dir) + + paths_str = os.environ.get("AVALON_ACTIONS") or "" + if paths_str: + self.log.warning( + "WARNING: 'AVALON_ACTIONS' is deprecated. Support of this" + " environment variable will be removed in future versions." + " Please consider using 'OpenPypeModule' to define custom" + " action paths. Planned version to drop the support" + " is 3.17.2 or 3.18.0 ." + ) + + for path in paths_str.split(os.pathsep): + if path and os.path.exists(path): + register_launcher_action_path(path) def on_action_trigger(self): - self.show_launcher() + """Implementation for ITrayAction interface. - def show_launcher(self): - if self.window: - self.window.show() - self.window.raise_() - self.window.activateWindow() + Show launcher tool on action trigger. + """ + + self._show_launcher() + + def _create_window(self): + if self._window: + return + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_launcher.ui import LauncherWindow + else: + from openpype.tools.launcher import LauncherWindow + self._window = LauncherWindow() + + def _show_launcher(self): + if self._window is None: + return + self._window.show() + self._window.raise_() + self._window.activateWindow() diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index b488fe3e1f..feb1bd05d2 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -20,7 +20,13 @@ class LauncherAction(object): log.propagate = True def is_compatible(self, session): - """Return whether the class is compatible with the Session.""" + """Return whether the class is compatible with the Session. + + Args: + session (dict[str, Union[str, None]]): Session data with + AVALON_PROJECT, AVALON_ASSET and AVALON_TASK. + """ + return True def process(self, session, **kwargs): diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py new file mode 100644 index 0000000000..00502fe930 --- /dev/null +++ b/openpype/tools/ayon_launcher/abstract.py @@ -0,0 +1,297 @@ +from abc import ABCMeta, abstractmethod + +import six + + +@six.add_metaclass(ABCMeta) +class AbstractLauncherCommon(object): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register event callback. + + Listen for events with given topic. + + Args: + topic (str): Name of topic. + callback (Callable): Callback that will be called when event + is triggered. + """ + + pass + + +class AbstractLauncherBackend(AbstractLauncherCommon): + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event. + + Args: + topic (str): Event topic used for callbacks filtering. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_project_settings(self, project_name): + """Project settings for current project. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_project_entity(self, project_name): + """Get project entity by name. + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + + @abstractmethod + def get_folder_entity(self, project_name, folder_id): + """Get folder entity by id. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, project_name, task_id): + """Get task entity by id. + + Args: + project_name (str): Project name. + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + +class AbstractLauncherFrontEnd(AbstractLauncherCommon): + # Entity items for UI + @abstractmethod + def get_project_items(self, sender=None): + """Project items for all projects. + + This function may trigger events 'projects.refresh.started' and + 'projects.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of project items in UI elements. + + Args: + sender (str): Who requested folder items. + + Returns: + list[ProjectItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items to visualize project hierarchy. + + This function may trigger events 'folders.refresh.started' and + 'folders.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of folder items in UI elements. + + Args: + project_name (str): Project name. + sender (str): Who requested folder items. + + Returns: + list[FolderItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_task_items(self, project_name, folder_id, sender=None): + """Task items. + + This function may trigger events 'tasks.refresh.started' and + 'tasks.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of task items in UI elements. + + Args: + project_name (str): Project name. + folder_id (str): Folder ID for which are tasks requested. + sender (str): Who requested folder items. + + Returns: + list[TaskItem]: Minimum possible information needed + for visualisation of tasks. + """ + + pass + + @abstractmethod + def get_selected_project_name(self): + """Selected project name. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_id(self): + """Selected folder id. + + Returns: + Union[str, None]: Selected folder id. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Selected task id. + + Returns: + Union[str, None]: Selected task id. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Selected task name. + + Returns: + Union[str, None]: Selected task name. + """ + + pass + + @abstractmethod + def get_selected_context(self): + """Get whole selected context. + + Example: + { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Change selected folder. + + Args: + project_name (Union[str, None]): Project nameor None if no project + is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, task_id, task_name): + """Change selected task. + + Args: + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + # Actions + @abstractmethod + def get_action_items(self, project_name, folder_id, task_id): + """Get action items for given context. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of action items that should be shown + for given context. + """ + + pass + + @abstractmethod + def trigger_action(self, project_name, folder_id, task_id, action_id): + """Trigger action on given context. + + Args: + 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. + """ + + pass + + @abstractmethod + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, enabled + ): + """This is application action related to force not open last workfile. + + Args: + 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. + enabled (bool): New value of force not open workfile. + """ + + pass + + @abstractmethod + def refresh(self): + """Refresh everything, models, ui etc. + + Triggers 'controller.refresh.started' event at the beginning and + 'controller.refresh.finished' at the end. + """ + + pass diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py new file mode 100644 index 0000000000..09e07893c3 --- /dev/null +++ b/openpype/tools/ayon_launcher/control.py @@ -0,0 +1,149 @@ +from openpype.lib import Logger +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel + +from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend +from .models import LauncherSelectionModel, ActionsModel + + +class BaseLauncherController( + AbstractLauncherFrontEnd, AbstractLauncherBackend +): + def __init__(self): + self._project_settings = {} + self._event_system = None + self._log = None + + self._selection_model = LauncherSelectionModel(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._actions_model = ActionsModel(self) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + @property + def event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + # --------------------------------- + # 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) + + # Entity items for UI + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender) + + # Project settings for applications actions + def get_project_settings(self, project_name): + if project_name in self._project_settings: + return self._project_settings[project_name] + settings = get_project_settings(project_name) + self._project_settings[project_name] = settings + return settings + + # Entity for backend + def get_project_entity(self, project_name): + return self._projects_model.get_project_entity(project_name) + + def get_folder_entity(self, project_name, folder_id): + return self._hierarchy_model.get_folder_entity( + project_name, folder_id) + + def get_task_entity(self, project_name, task_id): + return self._hierarchy_model.get_task_entity(project_name, task_id) + + # Selection methods + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def get_selected_context(self): + return { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + # Actions + def get_action_items(self, project_name, folder_id, task_id): + return self._actions_model.get_action_items( + project_name, folder_id, task_id) + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, enabled + ): + self._actions_model.set_application_force_not_open_workfile( + project_name, folder_id, task_id, action_id, enabled + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + self._actions_model.trigger_action( + project_name, folder_id, task_id, identifier) + + # General methods + def refresh(self): + self._emit_event("controller.refresh.started") + + self._project_settings = {} + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._actions_model.refresh() + self._projects_model.refresh() + + self._emit_event("controller.refresh.finished") + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") diff --git a/openpype/tools/ayon_launcher/models/__init__.py b/openpype/tools/ayon_launcher/models/__init__.py new file mode 100644 index 0000000000..1bc60c85f0 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/__init__.py @@ -0,0 +1,8 @@ +from .actions import ActionsModel +from .selection import LauncherSelectionModel + + +__all__ = ( + "ActionsModel", + "LauncherSelectionModel", +) diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py new file mode 100644 index 0000000000..24fea44db2 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -0,0 +1,505 @@ +import os + +from openpype import resources +from openpype.lib import Logger, OpenPypeSettingsRegistry +from openpype.pipeline.actions import ( + discover_launcher_actions, + LauncherAction, +) + + +# class Action: +# def __init__(self, label, icon=None, identifier=None): +# self._label = label +# self._icon = icon +# self._callbacks = [] +# self._identifier = identifier or uuid.uuid4().hex +# self._checked = True +# self._checkable = False +# +# def set_checked(self, checked): +# self._checked = checked +# +# def set_checkable(self, checkable): +# self._checkable = checkable +# +# def set_label(self, label): +# self._label = label +# +# def add_callback(self, callback): +# self._callbacks = callback +# +# +# class Menu: +# def __init__(self, label, icon=None): +# self.label = label +# self.icon = icon +# self._actions = [] +# +# def add_action(self, action): +# self._actions.append(action) + + +class ApplicationAction(LauncherAction): + """Action to launch an application. + + Application action based on 'ApplicationManager' system. + + Handling of applications in launcher is not ideal and should be completely + redone from scratch. This is just a temporary solution to keep backwards + compatibility with OpenPype launcher. + + Todos: + Move handling of errors to frontend. + """ + + # Application object + application = None + # Action attributes + name = None + label = None + label_variant = None + group = None + icon = None + color = None + order = 0 + data = {} + project_settings = {} + project_entities = {} + + _log = None + required_session_keys = ( + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK" + ) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def is_compatible(self, session): + for key in self.required_session_keys: + if not session.get(key): + return False + + project_name = session["AVALON_PROJECT"] + project_entity = self.project_entities[project_name] + apps = project_entity["attrib"].get("applications") + if not apps or self.application.full_name not in apps: + return False + + project_settings = self.project_settings[project_name] + only_available = project_settings["applications"]["only_available"] + if only_available and not self.application.find_executable(): + return False + return True + + def _show_message_box(self, title, message, details=None): + from qtpy import QtWidgets, QtGui + from openpype import style + + dialog = QtWidgets.QMessageBox() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + dialog.setWindowIcon(icon) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle(title) + dialog.setText(message) + if details: + dialog.setDetailedText(details) + dialog.exec_() + + def process(self, session, **kwargs): + """Process the full Application action""" + + from openpype.lib import ( + ApplictionExecutableNotFound, + ApplicationLaunchFailed, + ) + + project_name = session["AVALON_PROJECT"] + asset_name = session["AVALON_ASSET"] + task_name = session["AVALON_TASK"] + try: + self.application.launch( + project_name=project_name, + asset_name=asset_name, + task_name=task_name, + **self.data + ) + + except ApplictionExecutableNotFound as exc: + details = exc.details + msg = exc.msg + log_msg = str(msg) + if details: + log_msg += "\n" + details + self.log.warning(log_msg) + self._show_message_box( + "Application executable not found", msg, details + ) + + except ApplicationLaunchFailed as exc: + msg = str(exc) + self.log.warning(msg, exc_info=True) + self._show_message_box("Application launch failed", msg) + + +class ActionItem: + """Item representing single action to trigger. + + Todos: + Get rid of application specific logic. + + Args: + identifier (str): Unique identifier of action item. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + icon (dict[str, str]): Icon definition. + order (int): Action ordering. + is_application (bool): Is action application action. + force_not_open_workfile (bool): Force not open workfile. Application + related. + full_label (Optional[str]): Full label, if not set it is generated + from 'label' and 'variant_label'. + """ + + def __init__( + self, + identifier, + label, + variant_label, + icon, + order, + is_application, + force_not_open_workfile, + full_label=None + ): + self.identifier = identifier + self.label = label + self.variant_label = variant_label + self.icon = icon + self.order = order + self.is_application = is_application + self.force_not_open_workfile = force_not_open_workfile + self._full_label = full_label + + def copy(self): + return self.from_data(self.to_data()) + + @property + def full_label(self): + if self._full_label is None: + if self.variant_label: + self._full_label = " ".join([self.label, self.variant_label]) + else: + self._full_label = self.label + return self._full_label + + def to_data(self): + return { + "identifier": self.identifier, + "label": self.label, + "variant_label": self.variant_label, + "icon": self.icon, + "order": self.order, + "is_application": self.is_application, + "force_not_open_workfile": self.force_not_open_workfile, + "full_label": self._full_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +def get_action_icon(action): + """Get action icon info. + + Args: + action (LacunherAction): Action instance. + + Returns: + dict[str, str]: Icon info. + """ + + icon = action.icon + if not icon: + return { + "type": "awesome-font", + "name": "fa.cube", + "color": "white" + } + + if isinstance(icon, dict): + return icon + + icon_path = resources.get_resource(icon) + if not os.path.exists(icon_path): + try: + icon_path = icon.format(resources.RESOURCES_DIR) + except Exception: + pass + + if os.path.exists(icon_path): + return { + "type": "path", + "path": icon_path, + } + + return { + "type": "awesome-font", + "name": icon, + "color": action.color or "white" + } + + +class ActionsModel: + """Actions model. + + Args: + controller (AbstractLauncherBackend): Controller instance. + """ + + _not_open_workfile_reg_key = "force_not_open_workfile" + + def __init__(self, controller): + self._controller = controller + + self._log = None + + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool") + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def refresh(self): + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._controller.emit_event("actions.refresh.started") + self._get_action_objects() + self._controller.emit_event("actions.refresh.finished") + + def get_action_items(self, project_name, folder_id, task_id): + """Get actions for project. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of actions. + """ + + not_open_workfile_actions = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id) + session = self._prepare_session(project_name, folder_id, task_id) + output = [] + action_items = self._get_action_items(project_name) + for identifier, action in self._get_action_objects().items(): + if not action.is_compatible(session): + continue + + action_item = action_items[identifier] + # Handling of 'force_not_open_workfile' for applications + if action_item.is_application: + action_item = action_item.copy() + action_item.force_not_open_workfile = ( + not_open_workfile_actions.get(identifier, False) + ) + + output.append(action_item) + return output + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, 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 + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + session = self._prepare_session(project_name, folder_id, task_id) + failed = False + error_message = None + action_label = identifier + action_items = self._get_action_items(project_name) + try: + action = self._actions[identifier] + action_item = action_items[identifier] + action_label = action_item.full_label + self._controller.emit_event( + "action.trigger.started", + { + "identifier": identifier, + "full_label": action_label, + } + ) + if isinstance(action, ApplicationAction): + per_action = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id + ) + force_not_open_workfile = per_action.get(identifier, False) + action.data["start_last_workfile"] = force_not_open_workfile + action.process(session) + except Exception as exc: + self.log.warning("Action trigger failed.", exc_info=True) + failed = True + error_message = str(exc) + + self._controller.emit_event( + "action.trigger.finished", + { + "identifier": identifier, + "failed": failed, + "error_message": error_message, + "full_label": action_label, + } + ) + + def _get_no_last_workfile_reg_data(self): + try: + no_workfile_reg_data = self._launcher_tool_reg.get_item( + self._not_open_workfile_reg_key) + except ValueError: + no_workfile_reg_data = {} + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data) + return no_workfile_reg_data + + def _get_no_last_workfile_for_context( + self, project_name, folder_id, task_id + ): + not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() + return ( + not_open_workfile_reg_data + .get(project_name, {}) + .get(folder_id, {}) + .get(task_id, {}) + ) + + def _prepare_session(self, project_name, folder_id, task_id): + folder_name = None + if folder_id: + folder = self._controller.get_folder_entity( + project_name, folder_id) + if folder: + folder_name = folder["name"] + + task_name = None + if task_id: + task = self._controller.get_task_entity(project_name, task_id) + if task: + task_name = task["name"] + + return { + "AVALON_PROJECT": project_name, + "AVALON_ASSET": folder_name, + "AVALON_TASK": task_name, + } + + def _get_discovered_action_classes(self): + if self._discovered_actions is None: + self._discovered_actions = ( + discover_launcher_actions() + + self._get_applications_action_classes() + ) + return self._discovered_actions + + def _get_action_objects(self): + if self._actions is None: + actions = {} + for cls in self._get_discovered_action_classes(): + obj = cls() + identifier = getattr(obj, "identifier", None) + if identifier is None: + identifier = cls.__name__ + actions[identifier] = obj + self._actions = actions + return self._actions + + def _get_action_items(self, project_name): + action_items = self._action_items.get(project_name) + if action_items is not None: + return action_items + + project_entity = None + if project_name: + project_entity = self._controller.get_project_entity(project_name) + project_settings = self._controller.get_project_settings(project_name) + + action_items = {} + for identifier, action in self._get_action_objects().items(): + is_application = isinstance(action, ApplicationAction) + if is_application: + action.project_entities[project_name] = project_entity + action.project_settings[project_name] = project_settings + label = action.label or identifier + variant_label = getattr(action, "label_variant", None) + icon = get_action_icon(action) + item = ActionItem( + identifier, + label, + variant_label, + icon, + action.order, + is_application, + False + ) + action_items[identifier] = item + self._action_items[project_name] = action_items + return action_items + + def _get_applications_action_classes(self): + from openpype.lib.applications import ( + CUSTOM_LAUNCH_APP_GROUPS, + ApplicationManager, + ) + + actions = [] + + manager = ApplicationManager() + for full_name, application in manager.applications.items(): + if ( + application.group.name in CUSTOM_LAUNCH_APP_GROUPS + or not application.enabled + ): + continue + + action = type( + "app_{}".format(full_name), + (ApplicationAction,), + { + "identifier": "application.{}".format(full_name), + "application": application, + "name": application.name, + "label": application.group.label, + "label_variant": application.label, + "group": None, + "icon": application.icon, + "color": getattr(application, "color", None), + "order": getattr(application, "order", None) or 0, + "data": {} + } + ) + actions.append(action) + return actions diff --git a/openpype/tools/ayon_launcher/models/selection.py b/openpype/tools/ayon_launcher/models/selection.py new file mode 100644 index 0000000000..b156d2084c --- /dev/null +++ b/openpype/tools/ayon_launcher/models/selection.py @@ -0,0 +1,72 @@ +class LauncherSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "launcher.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_launcher/ui/__init__.py b/openpype/tools/ayon_launcher/ui/__init__.py new file mode 100644 index 0000000000..da30c84656 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LauncherWindow + + +__all__ = ( + "LauncherWindow", +) diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py new file mode 100644 index 0000000000..d04f8f8d24 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -0,0 +1,453 @@ +import time +import collections + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.ayon_utils.widgets import get_qt_icon + +from .resources import get_options_image_path + +ANIMATION_LEN = 7 + +ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 +ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 +FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 + + +class ActionsQtModel(QtGui.QStandardItemModel): + """Qt model for actions. + + Args: + controller (AbstractLauncherFrontEnd): Controller instance. + """ + + refreshed = QtCore.Signal() + + 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, + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_selection_folder_changed, + ) + controller.register_event_callback( + "selection.task.changed", + self._on_selection_task_changed, + ) + + self._controller = controller + + self._items_by_id = {} + self._groups_by_id = {} + + self._selected_project_name = None + self._selected_folder_id = None + self._selected_task_id = None + + def get_selected_project_name(self): + return self._selected_project_name + + def get_selected_folder_id(self): + return self._selected_folder_id + + def get_selected_task_id(self): + return self._selected_task_id + + def get_group_items(self, action_id): + return self._groups_by_id[action_id] + + def get_item_by_id(self, action_id): + return self._items_by_id.get(action_id) + + def _clear_items(self): + self._items_by_id = {} + self._groups_by_id = {} + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + + def refresh(self): + items = self._controller.get_action_items( + self._selected_project_name, + self._selected_folder_id, + self._selected_task_id, + ) + if not items: + self._clear_items() + self.refreshed.emit() + return + + root_item = self.invisibleRootItem() + + all_action_items_info = [] + items_by_label = collections.defaultdict(list) + for item in items: + if not item.variant_label: + all_action_items_info.append((item, False)) + else: + items_by_label[item.label].append(item) + + groups_by_id = {} + for action_items in items_by_label.values(): + 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 = {} + for action_item_info in all_action_items_info: + action_item, is_group = action_item_info + icon = get_qt_icon(action_item.icon) + if is_group: + label = action_item.label + else: + label = action_item.full_label + + item = self._items_by_id.get(action_item.identifier) + if item is None: + item = QtGui.QStandardItem() + item.setData(action_item.identifier, ACTION_ID_ROLE) + new_items.append(item) + + item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData(label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(action_item.order, ACTION_SORT_ROLE) + item.setData( + action_item.is_application, ACTION_IS_APPLICATION_ROLE) + item.setData( + action_item.force_not_open_workfile, + FORCE_NOT_OPEN_WORKFILE_ROLE) + items_by_id[action_item.identifier] = item + + if new_items: + root_item.appendRows(new_items) + + 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) + root_item.removeRow(item.row()) + + self._groups_by_id = groups_by_id + self._items_by_id = 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 + self._selected_task_id = None + self.refresh() + + def _on_selection_folder_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = None + self.refresh() + + def _on_selection_task_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self.refresh() + + +class ActionDelegate(QtWidgets.QStyledItemDelegate): + _cached_extender = {} + + def __init__(self, *args, **kwargs): + super(ActionDelegate, self).__init__(*args, **kwargs) + self._anim_start_color = QtGui.QColor(178, 255, 246) + self._anim_end_color = QtGui.QColor(5, 44, 50) + + def _draw_animation(self, painter, option, index): + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + rect_offset = grid_size.width() / 20 + size = grid_size.width() - (rect_offset * 2) + anim_rect = QtCore.QRect( + item_x + rect_offset, + option.rect.y() + rect_offset, + size, + size + ) + + painter.save() + + painter.setBrush(QtCore.Qt.transparent) + + gradient = QtGui.QConicalGradient() + gradient.setCenter(QtCore.QPointF(anim_rect.center())) + gradient.setColorAt(0, self._anim_start_color) + gradient.setColorAt(1, self._anim_end_color) + + time_diff = time.time() - index.data(ANIMATION_START_ROLE) + + # Repeat 4 times + part_anim = 2.5 + part_time = time_diff % part_anim + offset = (part_time / part_anim) * 360 + angle = (offset + 90) % 360 + + gradient.setAngle(-angle) + + pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + painter.drawArc( + anim_rect, + -16 * (angle + 10), + -16 * offset + ) + + painter.restore() + + @classmethod + def _get_extender_pixmap(cls, size): + pix = cls._cached_extender.get(size) + if pix is not None: + return pix + pix = QtGui.QPixmap(get_options_image_path()).scaled( + size, size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + cls._cached_extender[size] = pix + return pix + + def paint(self, painter, option, index): + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + if index.data(ANIMATION_STATE_ROLE): + self._draw_animation(painter, option, index) + + super(ActionDelegate, self).paint(painter, option, index) + + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + rect = QtCore.QRectF( + option.rect.x(), option.rect.height(), 5, 5) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QColor(200, 0, 0)) + painter.drawEllipse(rect) + + if not index.data(ACTION_IS_GROUP_ROLE): + return + + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + + tenth_size = int(grid_size.width() / 10) + extender_size = int(tenth_size * 2.4) + + extender_x = item_x + tenth_size + extender_y = option.rect.y() + tenth_size + + pix = self._get_extender_pixmap(extender_size) + painter.drawPixmap(extender_x, extender_y, pix) + + +class ActionsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(ActionsWidget, self).__init__(parent) + + self._controller = controller + + view = QtWidgets.QListView(self) + view.setProperty("mode", "icon") + view.setObjectName("IconView") + view.setViewMode(QtWidgets.QListView.IconMode) + view.setResizeMode(QtWidgets.QListView.Adjust) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + view.setWrapping(True) + view.setGridSize(QtCore.QSize(70, 75)) + view.setIconSize(QtCore.QSize(30, 30)) + view.setSpacing(0) + view.setWordWrap(True) + + # Make view flickable + flick = FlickCharm(parent=view) + flick.activateOn(view) + + model = ActionsQtModel(controller) + + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setSortRole(ACTION_SORT_ROLE) + + proxy_model.setSourceModel(model) + view.setModel(proxy_model) + + delegate = ActionDelegate(self) + view.setItemDelegate(delegate) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view) + + animation_timer = QtCore.QTimer() + animation_timer.setInterval(40) + animation_timer.timeout.connect(self._on_animation) + + view.clicked.connect(self._on_clicked) + view.customContextMenuRequested.connect(self._on_context_menu) + model.refreshed.connect(self._on_model_refresh) + + self._animated_items = set() + self._animation_timer = animation_timer + + self._context_menu = None + + self._flick = flick + self._view = view + self._model = model + self._proxy_model = proxy_model + + self._set_row_height(1) + + def _set_row_height(self, rows): + self.setMinimumHeight(rows * 75) + + def _on_model_refresh(self): + self._proxy_model.sort(0) + + def _on_animation(self): + time_now = time.time() + for action_id in tuple(self._animated_items): + item = self._model.get_item_by_id(action_id) + if item is None: + self._animated_items.discard(action_id) + continue + + start_time = item.data(ANIMATION_START_ROLE) + if start_time is None or (time_now - start_time) > ANIMATION_LEN: + item.setData(0, ANIMATION_STATE_ROLE) + self._animated_items.discard(action_id) + + if not self._animated_items: + self._animation_timer.stop() + + self.update() + + def _start_animation(self, index): + # Offset refresh timout + model_index = self._proxy_model.mapToSource(index) + if not model_index.isValid(): + return + action_id = model_index.data(ACTION_ID_ROLE) + self._model.setData(model_index, time.time(), ANIMATION_START_ROLE) + self._model.setData(model_index, 1, ANIMATION_STATE_ROLE) + self._animated_items.add(action_id) + self._animation_timer.start() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.data(ACTION_IS_APPLICATION_ROLE): + return + + menu = QtWidgets.QMenu(self._view) + checkbox = QtWidgets.QCheckBox( + "Skip opening last workfile.", menu) + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + checkbox.setChecked(True) + + action_id = index.data(ACTION_ID_ROLE) + checkbox.stateChanged.connect( + lambda: self._on_checkbox_changed( + action_id, checkbox.isChecked() + ) + ) + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + menu.addAction(action) + + self._context_menu = menu + global_point = self.mapToGlobal(point) + menu.exec_(global_point) + self._context_menu = None + + def _on_checkbox_changed(self, action_id, is_checked): + if self._context_menu is not None: + self._context_menu.close() + + project_name = self._model.get_selected_project_name() + 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) + self._model.refresh() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + is_group = index.data(ACTION_IS_GROUP_ROLE) + action_id = index.data(ACTION_ID_ROLE) + + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + + if not is_group: + self._controller.trigger_action( + project_name, folder_id, task_id, action_id + ) + self._start_animation(index) + return + + action_items = self._model.get_group_items(action_id) + + menu = QtWidgets.QMenu(self) + actions_mapping = {} + + for action_item in action_items: + menu_action = QtWidgets.QAction(action_item.full_label) + menu.addAction(menu_action) + actions_mapping[menu_action] = action_item + + result = menu.exec_(QtGui.QCursor.pos()) + if not result: + return + + action_item = actions_mapping[result] + + self._controller.trigger_action( + project_name, folder_id, task_id, action_item.identifier + ) + self._start_animation(index) diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py new file mode 100644 index 0000000000..5047cdc692 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -0,0 +1,102 @@ +import qtawesome +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + PlaceholderLineEdit, + SquareButton, + RefreshButton, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) + + +class HierarchyPage(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(HierarchyPage, self).__init__(parent) + + # Header + header_widget = QtWidgets.QWidget(self) + + btn_back_icon = qtawesome.icon("fa.angle-left", color="white") + btn_back = SquareButton(header_widget) + btn_back.setIcon(btn_back_icon) + + projects_combobox = ProjectsCombobox(controller, header_widget) + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(btn_back, 0) + header_layout.addWidget(projects_combobox, 1) + header_layout.addWidget(refresh_btn, 0) + + # Body - Folders + Tasks selection + content_body = QtWidgets.QSplitter(self) + content_body.setContentsMargins(0, 0, 0, 0) + content_body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + content_body.setOrientation(QtCore.Qt.Horizontal) + + # - Folders widget with filter + folders_wrapper = QtWidgets.QWidget(content_body) + + folders_filter_text = PlaceholderLineEdit(folders_wrapper) + folders_filter_text.setPlaceholderText("Filter folders...") + + folders_widget = FoldersWidget(controller, folders_wrapper) + + folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper) + folders_wrapper_layout.setContentsMargins(0, 0, 0, 0) + folders_wrapper_layout.addWidget(folders_filter_text, 0) + folders_wrapper_layout.addWidget(folders_widget, 1) + + # - Tasks widget + tasks_widget = TasksWidget(controller, content_body) + + content_body.addWidget(folders_wrapper) + content_body.addWidget(tasks_widget) + content_body.setStretchFactor(0, 100) + content_body.setStretchFactor(1, 65) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(content_body, 1) + + btn_back.clicked.connect(self._on_back_clicked) + refresh_btn.clicked.connect(self._on_refreh_clicked) + folders_filter_text.textChanged.connect(self._on_filter_text_changed) + + self._is_visible = False + self._controller = controller + + self._btn_back = btn_back + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + # Post init + projects_combobox.set_listen_to_selection_change(self._is_visible) + + def set_page_visible(self, visible, project_name=None): + if self._is_visible == visible: + return + self._is_visible = visible + self._projects_combobox.set_listen_to_selection_change(visible) + if visible and project_name: + self._projects_combobox.set_selection(project_name) + + def _on_back_clicked(self): + self._controller.set_selected_project(None) + + def _on_refreh_clicked(self): + self._controller.refresh() + + def _on_filter_text_changed(self, text): + self._folders_widget.set_name_filer(text) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py new file mode 100644 index 0000000000..baa399d0ed --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -0,0 +1,135 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.utils import PlaceholderLineEdit, RefreshButton +from openpype.tools.ayon_utils.widgets import ( + ProjectsModel, + ProjectSortFilterProxy, +) +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER + + +class ProjectIconView(QtWidgets.QListView): + """Styled ListView that allows to toggle between icon and list mode. + + Toggling between the two modes is done by Right Mouse Click. + """ + + IconMode = 0 + ListMode = 1 + + def __init__(self, parent=None, mode=ListMode): + super(ProjectIconView, self).__init__(parent=parent) + + # Workaround for scrolling being super slow or fast when + # toggling between the two visual modes + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setObjectName("IconView") + + self._mode = None + self.set_mode(mode) + + def set_mode(self, mode): + if mode == self._mode: + return + + self._mode = mode + + if mode == self.IconMode: + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(True) + self.setWordWrap(True) + self.setGridSize(QtCore.QSize(151, 90)) + self.setIconSize(QtCore.QSize(50, 50)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.setProperty("mode", "icon") + self.style().polish(self) + + self.verticalScrollBar().setSingleStep(30) + + elif self.ListMode: + self.setProperty("mode", "list") + self.style().polish(self) + + self.setViewMode(QtWidgets.QListView.ListMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(False) + self.setWordWrap(False) + self.setIconSize(QtCore.QSize(20, 20)) + self.setGridSize(QtCore.QSize(100, 25)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.verticalScrollBar().setSingleStep(34) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.set_mode(int(not self._mode)) + return super(ProjectIconView, self).mousePressEvent(event) + + +class ProjectsWidget(QtWidgets.QWidget): + """Projects Page""" + def __init__(self, controller, parent=None): + super(ProjectsWidget, self).__init__(parent=parent) + + header_widget = QtWidgets.QWidget(self) + + projects_filter_text = PlaceholderLineEdit(header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(projects_filter_text, 1) + header_layout.addWidget(refresh_btn, 0) + + projects_view = ProjectIconView(parent=self) + projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) + flick = FlickCharm(parent=self) + flick.activateOn(projects_view) + projects_model = ProjectsModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.clicked.connect(self._on_view_clicked) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + + def _on_view_clicked(self, index): + if index.isValid(): + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) + + def _on_project_filter_change(self, text): + self._projects_proxy_model.setFilterFixedString(text) + + def _on_refresh_clicked(self): + self._controller.refresh() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/openpype/tools/ayon_launcher/ui/resources/__init__.py b/openpype/tools/ayon_launcher/ui/resources/__init__.py new file mode 100644 index 0000000000..27c59af2ba --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/resources/__init__.py @@ -0,0 +1,7 @@ +import os + +RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def get_options_image_path(): + return os.path.join(RESOURCES_DIR, "options.png") diff --git a/openpype/tools/ayon_launcher/ui/resources/options.png b/openpype/tools/ayon_launcher/ui/resources/options.png new file mode 100644 index 0000000000000000000000000000000000000000..a9617d0d1914634835f388c01479c672a8c8ffd7 GIT binary patch literal 1772 zcmb7^`B%~j7skJcd*X`HFm9u1j=6?t%;Iwb7j zwT|b(sB<3ljDb1wl4oJgfJ(AW&7Bpv&K*RI{SGm)zYu0jaC8|wa}(8OdA=SqaOrGq zQl&JypY*{sIl^1ofVAgks@SbOwLH%}dUorXyCcm^p8M0qpzTd|xz-DUnY^dOKTPeH z@AJ-CoGxRX%>y<5sp+n7Xk3?Ib+6{^?Cg%=d&rthvwyAl5BkShe>;4{WaeF*=5}tm ztt=TDXzceIC%r2%j9B($%Mh%fdW+^DZJMF}7T%OTA(YA8((QYh7f_5?|N5vQJC}2i zC0r;RJ>ErHqPC^l%s{3;^f+m96LRwULUx{B(Jk=u8K5KQK(F2Y*hE<*nGwM+6L<1iX zlK}t@mfr$Is`1VO02RGJA5=K0d~t%GY(ju`e_V2|K}V)SS?KCKM{H&_P7Y3Crp5mD z(`Z|5OJ5s)n{5lU+FfOFe`n~y1vEYAMcpmGYIrBiNw-)XnP|0HU<^ZOl*}9xr(R_s z-ktQ)Rp*UwVBUH#819d^g7kQOs&9HGq+ox_gXKk_@&4ncL<1u#E}<#6%&D0{)?q>@ z?Mq-Gs6s7mESn~{WhJteFd6)`mZ1U=UOLUm;q`|VcDE-YaVZ;Q~Te%Sofmu0UYVg!&X;RuJAt9igF;-A4LhWK?gT_dOlF*OOzD^7K zM0(b%SCz|j<0nL|yHR9|tdKubbevo`S#A{YvDG=|<+k;omrnFfC=_onNlmF}*P%aQ zz^g7hN^R`Pd;|)6L$)FXQese2SGyOAY{6{}Y=kB?Kv#n$PhZdf2ky zC~`-vdo>j`0wm``3CPJbVh^l!T^XN!WuMskOu14otdtzM{FZ(Ri1&Fi@A@7tB8dK~ zde}ZsK7UHkRttC-w4~8JO&(yw0aNwZM}4Ljqg`wPwrbtjQMQjA+>o+2e`K*~z`}^8 z$vMbbLM0Q2PC(c15PkyL_|q$ttu02DN;O3I7xQtiX;sVJ+ymk-oAE{EL?t$|+05dC zI@|D&m@;_t3WNN^wQ{8^-@~D`hWrV+gGfW9Ct4Myk1m@@3?wv$+$1JoCUMX~gcLy% zh~XxkM&oi1d}qIrrDlMz02j145nuS1%F=x$|b>QE}Uw_gWZT>QLRG z(V=0dJffRQo$`Clt5srtbzj?s=cCa_lk?tP8IX-f>!Ws5L@ck1<(p=DzydJQUDGyaDo3`;n#I_7#p1q zhXiA8A329}vHO`;fO84CeMF#Wsx?to(^2@VC=8r2mai3L)W}`t7f{yg}pV5{4$m~-H5NUJkTQN6t*Ssmw zEK7QvnhYK8CMbrSija`c4|B|61&Y%ub36cVDVz$Q7|_Ra`j1sDOF{i9-4hK;j#?7m zg4b(STtTXty_=b>KL8LX)j~j)sZmySrC>jPN|p?KUt2o6^)Rm87RP5K{aekI3Hu$u zbFte3N7JA8*R$kRE2H|HiFjp)Tf?0gH~)_%?0Q*P#>> cache = NestedCacheItem(levels=2) + >>> cache["a"]["b"].is_valid + False + >>> cache["a"]["b"].get_data() + None + >>> cache["a"]["b"] = 1 + >>> cache["a"]["b"].is_valid + True + >>> cache["a"]["b"].get_data() + 1 + >>> cache.reset() + >>> cache["a"]["b"].is_valid + False + + Args: + levels (int): Number of nested levels where read cache is stored. + default_factory (Optional[callable]): Function that returns default + value used on init and on reset. + lifetime (Optional[int]): Lifetime of the cache data in seconds. + _init_info (Optional[InitInfo]): Private argument. Init info for + nested cache where created from parent item. + """ + + def __init__( + self, levels=1, default_factory=None, lifetime=None, _init_info=None + ): + if levels < 1: + raise ValueError("Nested levels must be greater than 0") + self._data_by_key = {} + if _init_info is None: + _init_info = InitInfo(default_factory, lifetime) + self._init_info = _init_info + self._levels = levels + + def __getitem__(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + cache = self._data_by_key.get(key) + if cache is None: + if self._levels > 1: + cache = NestedCacheItem( + levels=self._levels - 1, + _init_info=self._init_info + ) + else: + cache = CacheItem( + self._init_info.default_factory, + self._init_info.lifetime + ) + self._data_by_key[key] = cache + return cache + + def __setitem__(self, key, value): + """Update cached data. + + Args: + key (str): Key of the cache item. + value (Any): Any data that are cached. + """ + + if self._levels > 1: + raise AttributeError(( + "{} does not support '__setitem__'. Lower nested level by {}" + ).format(self.__class__.__name__, self._levels - 1)) + cache = self[key] + cache.update_data(value) + + def get(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + return self[key] + + def reset(self): + """Reset cache.""" + + self._data_by_key = {} + + def set_lifetime(self, lifetime): + """Change lifetime of all children cache items. + + Args: + lifetime (int): Lifetime of the cache data in seconds. + """ + + self._init_info.lifetime = lifetime + for cache in self._data_by_key.values(): + cache.set_lifetime(lifetime) + + @property + def is_valid(self): + """Raise reasonable error when called on wront level. + + Raises: + AttributeError: If called on nested cache item. + """ + + raise AttributeError(( + "{} does not support 'is_valid'. Lower nested level by '{}'" + ).format(self.__class__.__name__, self._levels)) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py new file mode 100644 index 0000000000..8e01c557c5 --- /dev/null +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -0,0 +1,340 @@ +import collections +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import NestedCacheItem + +HIERARCHY_MODEL_SENDER = "hierarchy.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class FolderItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + entity_id (str): Folder id. + 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. + """ + + def __init__( + self, entity_id, parent_id, name, label, icon + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + if not icon: + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": get_default_entity_icon_color() + } + self.icon = icon + self.label = label or name + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "entity_id": self.entity_id, + "parent_id": self.parent_id, + "name": self.name, + "label": self.label, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +class TaskItem: + """Task item representing task entity on a server. + + Task is child of a folder. + + Task item has label that is used for display in UI. The label is by + default using task name and type. + + Args: + task_id (str): Task id. + 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. + """ + + def __init__( + self, task_id, name, task_type, parent_id, icon + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.male", + "color": get_default_entity_icon_color() + } + self.icon = icon + + self._label = None + + @property + def id(self): + """Alias for task_id. + + Returns: + str: Task id. + """ + + return self.task_id + + @property + def label(self): + """Label of task item for UI. + + Returns: + str: Label of task item. + """ + + if self._label is None: + self._label = "{} ({})".format(self.name, self.task_type) + return self._label + + def to_data(self): + """Converts task item to data. + + Returns: + dict[str, Any]: Task item data. + """ + + return { + "task_id": self.task_id, + "name": self.name, + "parent_id": self.parent_id, + "task_type": self.task_type, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-create task item from data. + + Args: + data (dict[str, Any]): Task item data. + + Returns: + TaskItem: Task item. + """ + + return cls(**data) + + +def _get_task_items_from_tasks(tasks): + """ + + Returns: + TaskItem: Task item. + """ + + output = [] + for task in tasks: + folder_id = task["folderId"] + output.append(TaskItem( + task["id"], + task["name"], + task["type"], + folder_id, + None + )) + return output + + +def _get_folder_item_from_hierarchy_item(item): + return FolderItem( + item["id"], + item["parentId"], + item["name"], + item["label"], + None + ) + + +class HierarchyModel(object): + """Model for project hierarchy items. + + Hierarchy items are folders and tasks. Folders can have as parent another + folder or project. Tasks can have as parent only folder. + """ + + def __init__(self, controller): + self._folders_items = NestedCacheItem(levels=1, default_factory=dict) + self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict) + + self._task_items = NestedCacheItem(levels=2, default_factory=dict) + self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict) + + self._folders_refreshing = set() + self._tasks_refreshing = set() + self._controller = controller + + def reset(self): + self._folders_items.reset() + self._folders_by_id.reset() + + self._task_items.reset() + self._tasks_by_id.reset() + + def refresh_project(self, project_name): + self._refresh_folders_cache(project_name) + + def get_folder_items(self, project_name, sender): + 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_task_items(self, project_name, folder_id, sender): + if not project_name or not folder_id: + return [] + + task_cache = self._task_items[project_name][folder_id] + if not task_cache.is_valid: + self._refresh_tasks_cache(project_name, folder_id, sender) + return task_cache.get_data() + + 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() + + 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() + + @contextlib.contextmanager + def _folder_refresh_event_manager(self, project_name, sender): + self._folders_refreshing.add(project_name) + self._controller.emit_event( + "folders.refresh.started", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "folders.refresh.finished", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + self._folders_refreshing.remove(project_name) + + @contextlib.contextmanager + def _task_refresh_event_manager( + self, project_name, folder_id, sender + ): + self._tasks_refreshing.add(folder_id) + self._controller.emit_event( + "tasks.refresh.started", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "tasks.refresh.finished", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + self._tasks_refreshing.discard(folder_id) + + def _refresh_folders_cache(self, project_name, sender=None): + if project_name in self._folders_refreshing: + return + + with self._folder_refresh_event_manager(project_name, sender): + folder_items = self._query_folders(project_name) + self._folders_items[project_name].update_data(folder_items) + + def _query_folders(self, project_name): + hierarchy = ayon_api.get_folders_hierarchy(project_name) + + folder_items = {} + hierachy_queue = collections.deque(hierarchy["hierarchy"]) + while hierachy_queue: + item = hierachy_queue.popleft() + folder_item = _get_folder_item_from_hierarchy_item(item) + folder_items[folder_item.entity_id] = folder_item + hierachy_queue.extend(item["children"] or []) + return folder_items + + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): + if folder_id in self._tasks_refreshing: + return + + with self._task_refresh_event_manager( + project_name, folder_id, sender + ): + task_items = self._query_tasks(project_name, folder_id) + self._task_items[project_name][folder_id] = task_items + + def _query_tasks(self, project_name, folder_id): + tasks = list(ayon_api.get_tasks( + project_name, + folder_ids=[folder_id], + fields={"id", "name", "label", "folderId", "type"} + )) + return _get_task_items_from_tasks(tasks) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py new file mode 100644 index 0000000000..ae3eeecea4 --- /dev/null +++ b/openpype/tools/ayon_utils/models/projects.py @@ -0,0 +1,145 @@ +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import CacheItem + +PROJECTS_MODEL_SENDER = "projects.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class ProjectItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + name (str): Project name. + active (Union[str, None]): Parent folder id. If 'None' then project + is parent. + """ + + def __init__(self, name, active, icon=None): + self.name = name + self.active = active + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.map", + "color": get_default_entity_icon_color(), + } + self.icon = icon + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "name": self.name, + "active": self.active, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +def _get_project_items_from_entitiy(projects): + """ + + Args: + projects (list[dict[str, Any]]): List of projects. + + Returns: + ProjectItem: Project item. + """ + + return [ + ProjectItem(project["name"], project["active"]) + for project in projects + ] + + +class ProjectsModel(object): + def __init__(self, controller): + self._projects_cache = CacheItem(default_factory=dict) + self._project_items_by_name = {} + self._projects_by_name = {} + + self._is_refreshing = False + self._controller = controller + + def reset(self): + self._projects_cache.reset() + self._project_items_by_name = {} + self._projects_by_name = {} + + def refresh(self): + self._refresh_projects_cache() + + def get_project_items(self, sender): + if not self._projects_cache.is_valid: + self._refresh_projects_cache(sender) + return self._projects_cache.get_data() + + def get_project_entity(self, project_name): + if project_name not in self._projects_by_name: + entity = None + if project_name: + entity = ayon_api.get_project(project_name) + self._projects_by_name[project_name] = entity + return self._projects_by_name[project_name] + + @contextlib.contextmanager + def _project_refresh_event_manager(self, sender): + self._is_refreshing = True + self._controller.emit_event( + "projects.refresh.started", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "projects.refresh.finished", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + self._is_refreshing = False + + def _refresh_projects_cache(self, sender=None): + if self._is_refreshing: + return + + with self._project_refresh_event_manager(sender): + project_items = self._query_projects() + self._projects_cache.update_data(project_items) + + def _query_projects(self): + projects = ayon_api.get_projects(fields=["name", "active"]) + return _get_project_items_from_entitiy(projects) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py new file mode 100644 index 0000000000..59aef98faf --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -0,0 +1,37 @@ +from .projects_widget import ( + # ProjectsWidget, + ProjectsCombobox, + ProjectsModel, + ProjectSortFilterProxy, +) + +from .folders_widget import ( + FoldersWidget, + FoldersModel, +) + +from .tasks_widget import ( + TasksWidget, + TasksModel, +) +from .utils import ( + get_qt_icon, + RefreshThread, +) + + +__all__ = ( + # "ProjectsWidget", + "ProjectsCombobox", + "ProjectsModel", + "ProjectSortFilterProxy", + + "FoldersWidget", + "FoldersModel", + + "TasksWidget", + "TasksModel", + + "get_qt_icon", + "RefreshThread", +) diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py new file mode 100644 index 0000000000..3fab64f657 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -0,0 +1,364 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) + +from .utils import RefreshThread, get_qt_icon + +SENDER_NAME = "qt_folders_model" +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 + + +class FoldersModel(QtGui.QStandardItemModel): + """Folders model which cares about refresh of folders. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(FoldersModel, self).__init__() + + self._controller = controller + self._items_by_id = {} + self._parent_id_by_id = {} + + self._refresh_threads = {} + self._current_refresh_thread = None + self._last_project_name = None + + self._has_content = False + self._is_refreshing = False + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + return self._is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._has_content + + def clear(self): + self._items_by_id = {} + self._parent_id_by_id = {} + self._has_content = False + super(FoldersModel, self).clear() + + def get_index_by_id(self, item_id): + """Get index by folder id. + + Returns: + QtCore.QModelIndex: Index of the folder. Can be invalid if folder + is not available. + """ + item = self._items_by_id.get(item_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def set_project_name(self, project_name): + """Refresh folders items. + + Refresh start thread because it can cause that controller can + start query from database if folders are not cached. + """ + + if not project_name: + self._last_project_name = project_name + self._current_refresh_thread = None + self._fill_items({}) + return + + self._is_refreshing = True + + if self._last_project_name != project_name: + self.clear() + self._last_project_name = project_name + + thread = self._refresh_threads.get(project_name) + if thread is not None: + self._current_refresh_thread = thread + return + + thread = RefreshThread( + project_name, + self._controller.get_folder_items, + project_name, + SENDER_NAME + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Folders are stored by id. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_items(thread.get_result()) + + 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._is_refreshing = False + self.refreshed.emit() + return + + self._has_content = True + + folder_ids = set(folder_items_by_id) + ids_to_remove = set(self._items_by_id) - folder_ids + + folder_items_by_parent = collections.defaultdict(dict) + for folder_item in folder_items_by_id.values(): + ( + folder_items_by_parent + [folder_item.parent_id] + [folder_item.entity_id] + ) = folder_item + + hierarchy_queue = collections.deque() + hierarchy_queue.append((self.invisibleRootItem(), None)) + + # Keep pointers to removed items until the refresh finishes + # - some children of the items could be moved and reused elsewhere + removed_items = [] + while hierarchy_queue: + item = hierarchy_queue.popleft() + parent_item, parent_id = item + folder_items = folder_items_by_parent[parent_id] + + items_by_id = {} + 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) + if child_id in ids_to_remove: + removed_items.append(parent_item.takeRow(row_idx)) + else: + items_by_id[child_id] = child_item + + new_items = [] + for item_id in folder_ids_to_add: + folder_item = folder_items[item_id] + item = items_by_id.get(item_id) + if item is None: + is_new = True + item = QtGui.QStandardItem() + item.setEditable(False) + 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) + if is_new: + new_items.append(item) + self._items_by_id[item_id] = item + self._parent_id_by_id[item_id] = parent_id + + hierarchy_queue.append((item, item_id)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in ids_to_remove: + self._items_by_id.pop(item_id) + self._parent_id_by_id.pop(item_id) + + self._is_refreshing = False + self.refreshed.emit() + + +class FoldersWidget(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. + """ + + def __init__(self, controller, parent, handle_expected_selection=False): + super(FoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + + folders_model = FoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + + folders_view.setModel(folders_proxy_model) + + 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._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 _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"] != 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) + + 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) + if item_id is not None: + return item_id + return None + + def _on_selection_change(self): + item_id = self._get_selected_item_id() + self._controller.set_selected_folder(item_id) + + # 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 + 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) + 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 new file mode 100644 index 0000000000..818d574910 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -0,0 +1,325 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER +from .utils import RefreshThread, get_qt_icon + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 + + +class ProjectsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsModel, self).__init__() + self._controller = controller + + self._project_items = {} + + self._empty_item = None + self._empty_item_added = False + + self._is_refreshing = False + self._refresh_thread = None + + @property + def is_refreshing(self): + return self._is_refreshing + + def refresh(self): + self._refresh() + + def has_content(self): + return len(self._project_items) > 0 + + def _add_empty_item(self): + item = self._get_empty_item() + if not self._empty_item_added: + root_item = self.invisibleRootItem() + root_item.appendRow(item) + self._empty_item_added = True + + def _remove_empty_item(self): + if not self._empty_item_added: + return + + 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: + item = QtGui.QStandardItem("< No projects >") + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_item = item + return self._empty_item + + def _refresh(self): + if self._is_refreshing: + return + self._is_refreshing = True + refresh_thread = RefreshThread( + "projects", self._query_project_items + ) + refresh_thread.refresh_finished.connect(self._refresh_finished) + refresh_thread.start() + self._refresh_thread = refresh_thread + + def _query_project_items(self): + return self._controller.get_project_items() + + def _refresh_finished(self): + # TODO check if failed + result = self._refresh_thread.get_result() + self._refresh_thread = None + + self._fill_items(result) + + self._is_refreshing = False + self.refreshed.emit() + + def _fill_items(self, project_items): + items_to_remove = set(self._project_items.keys()) + 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 item is None: + item = QtGui.QStandardItem() + 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) + self._project_items[project_name] = item + + root_item = self.invisibleRootItem() + 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(): + self._remove_empty_item() + else: + self._add_empty_item() + + +class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + self._filter_inactive = True + # Disable case sensitivity + self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + def lessThan(self, left_index, right_index): + if left_index.data(PROJECT_NAME_ROLE) is None: + return True + + if right_index.data(PROJECT_NAME_ROLE) is None: + return False + + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active == left_is_active: + return super(ProjectSortFilterProxy, self).lessThan( + left_index, right_index + ) + + if left_is_active: + return True + return False + + def filterAcceptsRow(self, source_row, source_parent): + index = self.sourceModel().index(source_row, 0, source_parent) + string_pattern = self.filterRegularExpression().pattern() + 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() + + return super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + + def _custom_index_filter(self, index): + return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) + + def is_active_filter_enabled(self): + return self._filter_inactive + + def set_active_filter_enabled(self, value): + if self._filter_inactive == value: + return + self._filter_inactive = value + self.invalidateFilter() + + +class ProjectsCombobox(QtWidgets.QWidget): + def __init__(self, controller, parent, handle_expected_selection=False): + super(ProjectsCombobox, self).__init__(parent) + + projects_combobox = QtWidgets.QComboBox(self) + combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + projects_combobox.setItemDelegate(combobox_delegate) + projects_model = ProjectsModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_combobox.setModel(projects_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_combobox, 1) + + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_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 + ) + + projects_combobox.currentIndexChanged.connect( + self._on_current_index_changed + ) + + self._controller = controller + self._listen_selection_change = True + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + self._projects_combobox = projects_combobox + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._combobox_delegate = combobox_delegate + + def refresh(self): + self._projects_model.refresh() + + def set_selection(self, project_name): + """Set selection to a given project. + + Selection change is ignored if project is not found. + + Args: + project_name (str): Name of project. + + Returns: + bool: True if selection was changed, False otherwise. NOTE: + Selection may not be changed if project is not found, or if + project is already selected. + """ + + idx = self._projects_combobox.findData( + project_name, PROJECT_NAME_ROLE) + if idx < 0: + return False + if idx != self._projects_combobox.currentIndex(): + self._projects_combobox.setCurrentIndex(idx) + return True + return False + + def set_listen_to_selection_change(self, listen): + """Disable listening to changes of the selection. + + Because combobox is triggering selection change when it's model + is refreshed, it's necessary to disable listening to selection for + some cases, e.g. when is on a different page of UI and should be just + refreshed. + + Args: + listen (bool): Enable or disable listening to selection changes. + """ + + self._listen_selection_change = listen + + def get_current_project_name(self): + """Name of selected project. + + Returns: + Union[str, None]: Name of selected project, or None if no project + """ + + idx = self._projects_combobox.currentIndex() + if idx < 0: + return None + return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + + 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._controller.set_selected_project(project_name) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + if self._expected_selection: + self._set_expected_selection() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() + + def _on_controller_refresh(self): + self._update_expected_selection() + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + project_name = self._expected_selection + if project_name is not None: + if project_name != self.get_current_project_name(): + self.set_selection(project_name) + else: + # Fake project change + self._on_current_index_changed( + self._projects_combobox.currentIndex() + ) + + self._controller.expected_project_selected(project_name) + + 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() + + project_data = expected_data.get("project") + if ( + not project_data + or not project_data["current"] + or project_data["selected"] + ): + return + self._expected_selection = project_data["name"] + if not self._projects_model.is_refreshing: + self._set_expected_selection() + + +class ProjectsWidget(QtWidgets.QWidget): + # TODO implement + pass diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py new file mode 100644 index 0000000000..66ebd0b777 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -0,0 +1,436 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils import DeselectableTreeView + +from .utils import RefreshThread, get_qt_icon + +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 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model which cares about refresh of tasks by folder id. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(TasksModel, self).__init__() + + self._controller = controller + + self._items_by_name = {} + self._has_content = False + self._is_refreshing = False + + self._invalid_selection_item_used = False + self._invalid_selection_item = None + self._empty_tasks_item_used = False + self._empty_tasks_item = None + + self._last_project_name = None + self._last_folder_id = None + + self._refresh_threads = {} + self._current_refresh_thread = None + + # Initial state + self._add_invalid_selection_item() + + def clear(self): + self._items_by_name = {} + self._has_content = False + self._remove_invalid_items() + super(TasksModel, self).clear() + + def refresh(self, project_name, folder_id): + """Refresh tasks for folder. + + Args: + project_name (Union[str]): Name of project. + folder_id (Union[str, None]): Folder id. + """ + + self._refresh(project_name, folder_id) + + def get_index_by_name(self, task_name): + """Find item by name and return its index. + + Returns: + QtCore.QModelIndex: Index of item. Is invalid if task is not + found by name. + """ + + item = self._items_by_name.get(task_name) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_last_project_name(self): + """Get last refreshed project name. + + Returns: + Union[str, None]: Project name. + """ + + return self._last_project_name + + def get_last_folder_id(self): + """Get last refreshed folder id. + + Returns: + Union[str, None]: Folder id. + """ + + return self._last_folder_id + + def set_selected_project(self, project_name): + self._selected_project_name = project_name + + def _get_invalid_selection_item(self): + if self._invalid_selection_item is None: + item = QtGui.QStandardItem("Select a folder") + item.setFlags(QtCore.Qt.NoItemFlags) + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.times", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + self._invalid_selection_item = item + return self._invalid_selection_item + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.exclamation-circle", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _add_invalid_item(self, item): + self.clear() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_invalid_item(self, item): + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _remove_invalid_items(self): + self._remove_invalid_selection_item() + self._remove_empty_task_item() + + def _add_invalid_selection_item(self): + if not self._invalid_selection_item_used: + self._add_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = True + + def _remove_invalid_selection_item(self): + if self._invalid_selection_item: + self._remove_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = False + + def _add_empty_task_item(self): + if not self._empty_tasks_item_used: + self._add_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = True + + def _remove_empty_task_item(self): + if self._empty_tasks_item_used: + self._remove_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = False + + def _refresh(self, project_name, folder_id): + self._is_refreshing = True + self._last_project_name = project_name + self._last_folder_id = folder_id + if not folder_id: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread = self._refresh_threads.get(folder_id) + if thread is not None: + self._current_refresh_thread = thread + return + thread = RefreshThread( + folder_id, + self._controller.get_task_items, + project_name, + folder_id + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + task_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + new_items = [] + new_names = set() + for task_item in task_items: + name = task_item.name + new_names.add(name) + item = self._items_by_name.get(name) + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + self._items_by_name[name] = item + + # TODO cache locally + icon = get_qt_icon(task_item.icon) + item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + + for name in set(self._items_by_name) - new_names: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + self._has_content = root_item.rowCount() > 0 + self._is_refreshing = False + self.refreshed.emit() + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: Model is refreshing + """ + + return self._is_refreshing + + @property + def has_content(self): + """Model has content. + + Returns: + bools: Have at least one task. + """ + + return self._has_content + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksModel, self).headerData( + section, orientation, role + ) + + +class TasksWidget(QtWidgets.QWidget): + """Tasks widget. + + Widget that handles tasks view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): Workfiles controller. + parent (QtWidgets.QWidget): Parent widget. + handle_expected_selection (Optional[bool]): Handle expected selection. + """ + + def __init__(self, controller, parent, handle_expected_selection=False): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + + tasks_model = TasksModel(controller) + tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + + tasks_view.setModel(tasks_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + controller.register_event_callback( + "selection.folder.changed", + self._folder_selection_changed + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_tasks_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._selected_folder_id = None + + self._handle_expected_selection = handle_expected_selection + self._expected_selection_data = None + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + """Tasks were refreshed in controller. + + Ignore if refresh was triggered by tasks model, or refreshed folder is + not the same as currently selected folder. + + Args: + event (Event): Event object. + """ + + # Refresh only if current folder id is the same + if ( + event["sender"] == SENDER_NAME + or event["folder_id"] != self._selected_folder_id + ): + return + self._tasks_model.refresh( + event["project_name"], self._selected_folder_id + ) + + def _folder_selection_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._tasks_model.refresh( + event["project_name"], self._selected_folder_id + ) + + def _on_tasks_model_refresh(self): + if not self._set_expected_selection(): + self._on_selection_change() + self._tasks_proxy_model.sort(0) + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + for index in selection_model.selectedIndexes(): + task_id = index.data(ITEM_ID_ROLE) + task_name = index.data(ITEM_NAME_ROLE) + parent_id = index.data(PARENT_ID_ROLE) + if task_name is not None: + return parent_id, task_id, task_name + return self._selected_folder_id, None, None + + def _on_selection_change(self): + # Don't trigger task change during refresh + # - a task was deselected if that happens + # - can cause crash triggered during tasks refreshing + if self._tasks_model.is_refreshing: + return + + parent_id, task_id, task_name = self._get_selected_item_ids() + self._controller.set_selected_task(task_id, task_name) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return False + + if self._expected_selection_data is None: + return False + folder_id = self._expected_selection_data["folder_id"] + task_name = self._expected_selection_data["task_name"] + self._expected_selection_data = None + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id: + return False + if task_name is not None: + index = self._tasks_model.get_index_by_name(task_name) + if index.isValid(): + proxy_index = self._tasks_proxy_model.mapFromSource(index) + self._tasks_view.setCurrentIndex(proxy_index) + self._controller.expected_task_selected(folder_id, task_name) + return True + + 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") + task_data = expected_data.get("task") + if ( + not folder_data + or not task_data + or not task_data["current"] + ): + return + folder_id = folder_data["id"] + self._expected_selection_data = { + "task_name": task_data["name"], + "folder_id": folder_id, + } + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id or self._tasks_model.is_refreshing: + return + self._set_expected_selection() diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py new file mode 100644 index 0000000000..8bc3b1ea9b --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -0,0 +1,98 @@ +import os +from functools import partial + +from qtpy import QtCore, QtGui + +from openpype.tools.utils.lib import get_qta_icon_by_name_and_color + + +class RefreshThread(QtCore.QThread): + refresh_finished = QtCore.Signal(str) + + def __init__(self, thread_id, func, *args, **kwargs): + super(RefreshThread, self).__init__() + self._id = thread_id + self._callback = partial(func, *args, **kwargs) + self._exception = None + self._result = None + + @property + def id(self): + return self._id + + @property + def failed(self): + return self._exception is not None + + def run(self): + try: + self._result = self._callback() + except Exception as exc: + self._exception = exc + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class _IconsCache: + """Cache for icons.""" + + _cache = {} + _default = None + + @classmethod + def _get_cache_key(cls, icon_def): + parts = [] + icon_type = icon_def["type"] + if icon_type == "path": + parts = [icon_type, icon_def["path"]] + + elif icon_type == "awesome-font": + parts = [icon_type, icon_def["name"], icon_def["color"]] + return "|".join(parts) + + @classmethod + def get_icon(cls, icon_def): + icon_type = icon_def["type"] + cache_key = cls._get_cache_key(icon_def) + cache = cls._cache.get(cache_key) + if cache is not None: + return cache + + icon = None + if icon_type == "path": + path = icon_def["path"] + if os.path.exists(path): + icon = QtGui.QIcon(path) + + elif icon_type == "awesome-font": + icon_name = icon_def["name"] + icon_color = icon_def["color"] + icon = get_qta_icon_by_name_and_color(icon_name, icon_color) + if icon is None: + icon = get_qta_icon_by_name_and_color( + "fa.{}".format(icon_name), icon_color) + if icon is None: + icon = cls.get_default() + cls._cache[cache_key] = icon + return icon + + @classmethod + def get_default(cls): + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + return QtGui.QIcon(pix) + + +def get_qt_icon(icon_def): + """Returns icon from cache or creates new one. + + Args: + icon_def (dict[str, Any]): Icon definition. + + Returns: + QtGui.QIcon: Icon. + """ + + return _IconsCache.get_icon(icon_def) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 61660ee9b7..285b5d04ca 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -1,8 +1,5 @@ -import os - from qtpy import QtWidgets, QtGui -from openpype import PLUGINS_DIR from openpype import style from openpype import resources from openpype.lib import ( @@ -10,46 +7,7 @@ from openpype.lib import ( ApplictionExecutableNotFound, ApplicationLaunchFailed ) -from openpype.pipeline import ( - LauncherAction, - register_launcher_action_path, -) - - -def register_actions_from_paths(paths): - if not paths: - return - - for path in paths: - if not path: - continue - - if path.startswith("."): - print(( - "BUG: Relative paths are not allowed for security reasons. {}" - ).format(path)) - continue - - if not os.path.exists(path): - print("Path was not found: {}".format(path)) - continue - - register_launcher_action_path(path) - - -def register_config_actions(): - """Register actions from the configuration for Launcher""" - - actions_dir = os.path.join(PLUGINS_DIR, "actions") - if os.path.exists(actions_dir): - register_actions_from_paths([actions_dir]) - - -def register_environment_actions(): - """Register actions from AVALON_ACTIONS for Launcher.""" - - paths_str = os.environ.get("AVALON_ACTIONS") or "" - register_actions_from_paths(paths_str.split(os.pathsep)) +from openpype.pipeline import LauncherAction # TODO move to 'openpype.pipeline.actions' diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index d343353112..018088e916 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,10 @@ from .widgets import ( IconButton, PixmapButton, SeparatorWidget, + VerticalExpandButton, + SquareButton, + RefreshButton, + GoToCurrentButton, ) from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox @@ -60,6 +64,11 @@ __all__ = ( "PixmapButton", "SeparatorWidget", + "VerticalExpandButton", + "SquareButton", + "RefreshButton", + "GoToCurrentButton", + "DeselectableTreeView", "ErrorMessageBox", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index a70437cc65..9223afecaa 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -6,10 +6,13 @@ import qtawesome from openpype.style import ( get_objected_colors, - get_style_image_path + get_style_image_path, + get_default_tools_icon_color, ) from openpype.lib.attribute_definitions import AbstractAttrDef +from .lib import get_qta_icon_by_name_and_color + log = logging.getLogger(__name__) @@ -777,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame): self._orientation = orientation self._set_size(self._size) + + +def get_refresh_icon(): + return get_qta_icon_by_name_and_color( + "fa.refresh", get_default_tools_icon_color() + ) + + +def get_go_to_current_icon(): + return get_qta_icon_by_name_and_color( + "fa.arrow-down", get_default_tools_icon_color() + ) + + +class VerticalExpandButton(QtWidgets.QPushButton): + """Button which is expanding vertically. + + By default, button is a little bit smaller than other widgets like + QLineEdit. This button is expanding vertically to match size of + other widgets, next to it. + """ + + def __init__(self, parent=None): + super(VerticalExpandButton, self).__init__(parent) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + + +class SquareButton(QtWidgets.QPushButton): + """Make button square shape. + + Change width to match height on resize. + """ + + def __init__(self, *args, **kwargs): + super(SquareButton, self).__init__(*args, **kwargs) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + self._ideal_width = None + + def showEvent(self, event): + super(SquareButton, self).showEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def resizeEvent(self, event): + super(SquareButton, self).resizeEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def sizeHint(self): + sh = super(SquareButton, self).sizeHint() + ideal_width = self._ideal_width + if ideal_width is None: + ideal_width = sh.height() + sh.setWidth(ideal_width) + return sh + + +class RefreshButton(VerticalExpandButton): + def __init__(self, parent=None): + super(RefreshButton, self).__init__(parent) + self.setIcon(get_refresh_icon()) + + +class GoToCurrentButton(VerticalExpandButton): + def __init__(self, parent=None): + super(GoToCurrentButton, self).__init__(parent) + self.setIcon(get_go_to_current_icon()) From bfb5868417f1fbf127b65bdf8cb214585a2312b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 14:18:57 +0200 Subject: [PATCH 0186/1224] AYON: Fix task type short name conversion (#5641) * fix task type short name conversion * workfiles tool can query project entity * use project entity to fill task template data --- openpype/client/server/conversion_utils.py | 2 ++ openpype/tools/ayon_workfiles/abstract.py | 10 ++++++++++ openpype/tools/ayon_workfiles/control.py | 3 +++ .../tools/ayon_workfiles/models/hierarchy.py | 11 ++++++++++ .../tools/ayon_workfiles/models/workfiles.py | 20 ++++++++++++++----- 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index f67a1ef9c4..8c18cb1c13 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -235,6 +235,8 @@ def convert_v4_project_to_v3(project): new_task_types = {} for task_type in task_types: name = task_type.pop("name") + # Change 'shortName' to 'short_name' + task_type["short_name"] = task_type.pop("shortName", None) new_task_types[name] = task_type config["tasks"] = new_task_types diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py index e30a2c2499..f511181837 100644 --- a/openpype/tools/ayon_workfiles/abstract.py +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -442,6 +442,16 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): pass + @abstractmethod + def get_project_entity(self): + """Get current project entity. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + @abstractmethod def get_folder_entity(self, folder_id): """Get folder entity by id. diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index fc8819bff3..1153a3c01f 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -193,6 +193,9 @@ class BaseWorkfileController( self._project_anatomy = Anatomy(self.get_current_project_name()) return self._project_anatomy + def get_project_entity(self): + return self._entities_model.get_project_entity() + def get_folder_entity(self, folder_id): return self._entities_model.get_folder_entity(folder_id) diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py index 948c0b8a17..a1d51525da 100644 --- a/openpype/tools/ayon_workfiles/models/hierarchy.py +++ b/openpype/tools/ayon_workfiles/models/hierarchy.py @@ -77,8 +77,11 @@ class EntitiesModel(object): event_source = "entities.model" def __init__(self, controller): + project_cache = CacheItem() + project_cache.set_invalid({}) folders_cache = CacheItem() folders_cache.set_invalid({}) + self._project_cache = project_cache self._folders_cache = folders_cache self._tasks_cache = {} @@ -90,6 +93,7 @@ class EntitiesModel(object): self._controller = controller def reset(self): + self._project_cache.set_invalid({}) self._folders_cache.set_invalid({}) self._tasks_cache = {} @@ -99,6 +103,13 @@ class EntitiesModel(object): def refresh(self): self._refresh_folders_cache() + def get_project_entity(self): + if not self._project_cache.is_valid: + project_name = self._controller.get_current_project_name() + project_entity = ayon_api.get_project(project_name) + self._project_cache.update_data(project_entity) + return self._project_cache.get_data() + def get_folder_items(self, sender): if not self._folders_cache.is_valid: self._refresh_folders_cache(sender) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py index eb82f62de3..316d8b2a16 100644 --- a/openpype/tools/ayon_workfiles/models/workfiles.py +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -43,13 +43,21 @@ def get_folder_template_data(folder): } -def get_task_template_data(task): +def get_task_template_data(project_entity, task): if not task: return {} + short_name = None + task_type_name = task["taskType"] + for task_type_info in project_entity["config"]["taskTypes"]: + if task_type_info["name"] == task_type_name: + short_name = task_type_info["shortName"] + break + return { "task": { "name": task["name"], - "type": task["taskType"] + "type": task_type_name, + "short": short_name, } } @@ -145,12 +153,13 @@ class WorkareaModel: self._fill_data_by_folder_id[folder_id] = fill_data return copy.deepcopy(fill_data) - def _get_task_data(self, folder_id, task_id): + def _get_task_data(self, project_entity, folder_id, task_id): task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) if task_id not in task_data: task = self._controller.get_task_entity(task_id) if task: - task_data[task_id] = get_task_template_data(task) + task_data[task_id] = get_task_template_data( + project_entity, task) return copy.deepcopy(task_data[task_id]) def _prepare_fill_data(self, folder_id, task_id): @@ -159,7 +168,8 @@ class WorkareaModel: base_data = self._get_base_data() folder_data = self._get_folder_data(folder_id) - task_data = self._get_task_data(folder_id, task_id) + project_entity = self._controller.get_project_entity() + task_data = self._get_task_data(project_entity, folder_id, task_id) base_data.update(folder_data) base_data.update(task_data) From dd2255f8fd13919d8e3d2d2df02652a515f00a57 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Sep 2023 20:39:33 +0800 Subject: [PATCH 0187/1224] jakub's comment --- openpype/hosts/nuke/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index a814615164..fe7b52cd8a 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -808,8 +808,8 @@ class ExporterReviewMov(ExporterReview): filename = os.path.basename(self.path_in) self.file = filename if ".{}".format(self.ext) not in self.file: - wrg_ext = filename.split(".")[-1] - self.file = filename.replace(wrg_ext, self.ext) + original_ext = filename.split(".")[-1] + self.file = filename.replace(original_ext, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") From ed7b321f640617bc529d548b613186b4fbd3b7f8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 22 Sep 2023 16:18:04 +0300 Subject: [PATCH 0188/1224] BigRoy's comments --- openpype/hosts/houdini/api/lib.py | 19 +++++++++++-------- openpype/hosts/houdini/api/plugin.py | 6 +++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 27f5476894..0e5aa1e74a 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -776,6 +776,16 @@ def publisher_show_and_publish(comment=""): def self_publish(): """Self publish from ROP nodes.""" + result, comment = hou.ui.readInput( + "Add Publish Comment", + buttons=("Publish", "Cancel"), + title="Publish comment", + close_choice=1 + ) + + if result: + return + current_node = hou.node(".").path() host = registered_host() @@ -792,18 +802,11 @@ def self_publish(): context.save_changes() - result, comment = hou.ui.readInput( - "Add Publish Note", - buttons=("Ok", "Cancel"), - title="Publish Note", - close_choice=1 - ) - publisher_show_and_publish(comment) def add_self_publish_button(node): - """Adds a self publish button in the rop node.""" + """Adds a self publish button to the rop node.""" label = os.environ.get("AVALON_LABEL") or "OpenPype" diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 8670103a81..2cd7ff83e3 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -168,7 +168,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] settings_name = None - _add_self_publish_button = False + add_publish_button = False def create(self, subset_name, instance_data, pre_create_data): try: @@ -196,7 +196,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self._add_instance_to_context(instance) imprint(instance_node, instance.data_to_store()) - if self._add_self_publish_button: + if self.add_publish_button: add_self_publish_button(instance_node) return instance @@ -305,7 +305,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Method called on initialization of plugin to apply settings.""" # Apply General Settings - self._add_self_publish_button = \ + self.add_publish_button = \ project_settings["houdini"]["general"]["add_self_publish_button"] # Apply Creator Settings From d744a486d64134455ffc636fef92ce09c9742b5f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Sep 2023 21:38:30 +0800 Subject: [PATCH 0189/1224] edit the settings where deprecated_setting used when it enabled; current_setting adopted when deprecated_setting diabled in extract_reiew_baking_streams --- .../publish/extract_review_baking_streams.py | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py index 59a3f659c9..d9ae673c2c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py @@ -28,30 +28,18 @@ class ExtractReviewDataBakingStreams(publish.Extractor): @classmethod def apply_settings(cls, project_settings): """just in case there are some old presets - in deprecrated ExtractReviewDataMov Plugins + in deprecated ExtractReviewDataMov Plugins """ nuke_publish = project_settings["nuke"]["publish"] - deprecrated_review_settings = nuke_publish["ExtractReviewDataMov"] - current_review_settings = ( - nuke_publish["ExtractReviewDataBakingStreams"] - ) - if deprecrated_review_settings["viewer_lut_raw"] == ( - current_review_settings["viewer_lut_raw"] - ): - cls.viewer_lut_raw = ( - current_review_settings["viewer_lut_raw"] - ) + deprecated_setting = nuke_publish["ExtractReviewDataMov"] + current_setting = nuke_publish["ExtractReviewDataBakingStreams"] + if not deprecated_setting["enabled"]: + if current_setting["enabled"]: + cls.viewer_lut_raw = current_setting["viewer_lut_raw"] + cls.outputs = current_setting["outputs"] else: - cls.viewer_lut_raw = ( - deprecrated_review_settings["viewer_lut_raw"] - ) - - if deprecrated_review_settings["outputs"] == ( - current_review_settings["outputs"] - ): - cls.outputs = current_review_settings["outputs"] - else: - cls.outputs = deprecrated_review_settings["outputs"] + cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] + cls.outputs = deprecated_setting["outputs"] def process(self, instance): families = set(instance.data["families"]) From d498afbf489eefbe3a2733a17d6f8ebe988abf79 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Sep 2023 15:24:17 +0100 Subject: [PATCH 0190/1224] New family ue_yeticache, new creator and extractor --- .../plugins/create/create_unreal_yeticache.py | 39 ++++++++++++ .../plugins/publish/collect_yeti_cache.py | 2 +- .../publish/extract_unreal_yeticache.py | 62 +++++++++++++++++++ .../plugins/publish/collect_resources_path.py | 3 +- openpype/plugins/publish/integrate.py | 3 +- 5 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 openpype/hosts/maya/plugins/create/create_unreal_yeticache.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py diff --git a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py new file mode 100644 index 0000000000..8ff3dccea2 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py @@ -0,0 +1,39 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import NumberDef + + +class CreateYetiCache(plugin.MayaCreator): + """Output for procedural plugin nodes of Yeti """ + + identifier = "io.openpype.creators.maya.unrealyeticache" + label = "Unreal Yeti Cache" + family = "ue_yeticache" + icon = "pagelines" + + def get_instance_attr_defs(self): + + defs = [ + NumberDef("preroll", + label="Preroll", + minimum=0, + default=0, + decimals=0) + ] + + # Add animation data without step and handles + defs.extend(lib.collect_animation_defs()) + remove = {"step", "handleStart", "handleEnd"} + defs = [attr_def for attr_def in defs if attr_def.key not in remove] + + # Add samples after frame range + defs.append( + NumberDef("samples", + label="Samples", + default=3, + decimals=0) + ) + + return defs diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index 4dcda29050..426e330f89 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -39,7 +39,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.45 label = "Collect Yeti Cache" - families = ["yetiRig", "yeticache"] + families = ["yetiRig", "yeticache", "ue_yeticache"] hosts = ["maya"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py new file mode 100644 index 0000000000..2279d52111 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -0,0 +1,62 @@ +import os +import json + +from maya import cmds + +from openpype.pipeline import publish + + +class ExtractYetiCache(publish.Extractor): + """Producing Yeti cache files using scene time range. + + This will extract Yeti cache file sequence and fur settings. + """ + + label = "Extract Yeti Cache" + hosts = ["maya"] + families = ["ue_yeticache"] + + def process(self, instance): + + yeti_nodes = cmds.ls(instance, type="pgYetiMaya") + if not yeti_nodes: + raise RuntimeError("No pgYetiMaya nodes found in the instance") + + # Define extract output file path + dirname = self.staging_dir(instance) + + # Collect information for writing cache + start_frame = instance.data["frameStartHandle"] + end_frame = instance.data["frameEndHandle"] + preroll = instance.data["preroll"] + if preroll > 0: + start_frame -= preroll + + kwargs = {} + samples = instance.data.get("samples", 0) + if samples == 0: + kwargs.update({"sampleTimes": "0.0 1.0"}) + else: + kwargs.update({"samples": samples}) + + self.log.debug(f"Writing out cache {start_frame} - {end_frame}") + filename = "{0}.abc".format(instance.name) + path = os.path.join(dirname, filename) + cmds.pgYetiCommand(yeti_nodes, + writeAlembic=path, + range=(start_frame, end_frame), + asUnrealAbc=True, + **kwargs) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'abc', + 'ext': 'abc', + 'files': filename, + 'stagingDir': dirname + } + instance.data["representations"].append(representation) + + self.log.debug(f"Extracted {instance} to {dirname}") diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index f96dd0ae18..6840509f79 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -62,7 +62,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "effect", "staticMesh", "skeletalMesh", - "xgen" + "xgen", + "ue_yeticache" ] def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 7e48155b9e..24fbc0d8e7 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -139,7 +139,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "ue_yeticache" ] default_template_name = "publish" From 9781104f4e69f0d557fc4ec7e32afa8d26583e11 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Sep 2023 15:49:38 +0100 Subject: [PATCH 0191/1224] Implemented Unreal loader --- .../unreal/plugins/load/load_yeticache.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 openpype/hosts/unreal/plugins/load/load_yeticache.py diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py new file mode 100644 index 0000000000..59dbdbee31 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""Loader for Yeti Cache.""" +import os + +from openpype.pipeline import ( + get_representation_path, + AYON_CONTAINER_ID +) +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa + + +class YetiLoader(plugin.Loader): + """Load Yeti Cache""" + + families = ["ue_yeticache"] + label = "Import Yeti" + representations = ["abc"] + icon = "pagelines" + color = "orange" + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): + task = unreal.AssetImportTask() + options = unreal.AbcImportSettings() + + task.set_editor_property('filename', filename) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', replace) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + task.options = options + + return task + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + + """ + # Create directory for asset and Ayon container + root = "/Game/Ayon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + path = self.filepath_from_context(context) + task = self.get_task(path, asset_dir, asset_name, False) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + unreal_pipeline.create_container( + container=container_name, path=asset_dir) + + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = get_representation_path(representation) + destination_path = container["namespace"] + + task = self.get_task(source_path, destination_path, name, True) + + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + container_path = f'{container["namespace"]}/{container["objectName"]}' + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) From 41b207d657f8c4da054248791a946f0c86657000 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 22 Sep 2023 15:58:27 +0100 Subject: [PATCH 0192/1224] Hound fixes --- openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py index 2279d52111..816e125c33 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -1,5 +1,4 @@ import os -import json from maya import cmds From 1cd3a9e701cb6cea04844c6dec7a7672c8a2758d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 22 Sep 2023 23:00:58 +0300 Subject: [PATCH 0193/1224] make self publish to publish input dependencies --- openpype/hosts/houdini/api/lib.py | 39 ++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 0e5aa1e74a..3780087bd0 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -773,8 +773,37 @@ def publisher_show_and_publish(comment=""): publisher_window.click_publish() +def find_rop_input_dependencies(input_tuple): + """Self publish from ROP nodes. + + Arguments: + tuple (hou.RopNode.inputDependencies) which can be a nested tuples + represents the input dependencies of the ROP node, consisting of ROPs, + and the frames that need to be be rendered prior to rendering the ROP. + + Returns: + list of the RopNode.path() that can be found inside + the input tuple. + """ + + out_list = [] + if isinstance(input_tuple[0], hou.RopNode): + return input_tuple[0].path() + + if isinstance(input_tuple[0], tuple): + for item in input_tuple: + out_list.append(find_rop_input_dependencies(item)) + + return out_list + + def self_publish(): - """Self publish from ROP nodes.""" + """Self publish from ROP nodes. + + Firstly, it gets the node and its dependencies. + Then, it deactivates all other ROPs + And finaly, it triggers the publishing action. + """ result, comment = hou.ui.readInput( "Add Publish Comment", @@ -786,7 +815,11 @@ def self_publish(): if result: return - current_node = hou.node(".").path() + current_node = hou.node(".") + inputs_paths = find_rop_input_dependencies( + current_node.inputDependencies() + ) + inputs_paths.append(current_node.path()) host = registered_host() context = CreateContext(host, reset=True) @@ -796,7 +829,7 @@ def self_publish(): if not node_path: continue - active = current_node == node_path + active = node_path in inputs_paths instance["active"] = active hou.node(node_path).parm("active").set(active) From e27930c42064f9a6903bcedc0998c8e8b6e2eacb Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 23 Sep 2023 03:24:16 +0000 Subject: [PATCH 0194/1224] [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 483b70436a..d1ebde3d04 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.1-nightly.1" +__version__ = "3.17.1-nightly.2" From 60d75300114f9ebba17f3882a81627080ef92ac8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 23 Sep 2023 03:24:52 +0000 Subject: [PATCH 0195/1224] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0762eb2f20..87d904fc84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,8 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.1-nightly.2 + - 3.17.1-nightly.1 - 3.17.0 - 3.16.7 - 3.16.7-nightly.2 @@ -133,8 +135,6 @@ body: - 3.14.10-nightly.5 - 3.14.10-nightly.4 - 3.14.10-nightly.3 - - 3.14.10-nightly.2 - - 3.14.10-nightly.1 validations: required: true - type: dropdown From 21d547a085c1b06fc9f26829920b24dd0068d01b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Sun, 24 Sep 2023 12:49:30 +0800 Subject: [PATCH 0196/1224] introduce the function for checking the filename to see if it consists of the frame hashes element --- openpype/hosts/nuke/api/lib.py | 18 ++++++++++++++++++ openpype/hosts/nuke/api/plugin.py | 9 +++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 41e6a27cef..ed517b472c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3397,3 +3397,21 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): if path_like: return "{}/{}".format(display, viewer) return "{} ({})".format(viewer, display) + +def get_file_with_name_and_hashes(original_path, name): + """Function to get the ranmed filename with frame hashes + + Args: + original_path (str): the filename with frame hashes + name (str): the name of the tags + + Returns: + filename: the renamed filename with the tag + """ + filename = os.path.basename(original_path) + fhead = filename.split(".")[0] + if "#" in fhead: + fhead = fhead.replace("#", "")[:-1] + new_fhead = "{}.{}".format(fhead, name) + filename = filename.replace(fhead, new_fhead) + return filename diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index fe7b52cd8a..b7927738d6 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -38,7 +38,8 @@ from .lib import ( get_node_data, get_view_process_node, get_viewer_config_from_string, - deprecated + deprecated, + get_file_with_name_and_hashes ) from .pipeline import ( list_instances, @@ -805,11 +806,12 @@ 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) + filename = get_file_with_name_and_hashes( + self.path_in, self.name) self.file = filename if ".{}".format(self.ext) not in self.file: original_ext = filename.split(".")[-1] - self.file = filename.replace(original_ext, self.ext) + self.file = filename.replace(original_ext, ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") @@ -931,7 +933,6 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Path: {}".format(self.path)) write_node["file"].setValue(str(self.path)) write_node["file_type"].setValue(str(self.ext)) - self.log.debug("{0}".format(self.ext)) # Knobs `meta_codec` and `mov64_codec` are not available on centos. # TODO shouldn't this come from settings on outputs? try: From 6f858a80ca4839bb581685d48d4d5d6304ac5403 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Sun, 24 Sep 2023 12:50:42 +0800 Subject: [PATCH 0197/1224] hound --- openpype/hosts/nuke/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index ed517b472c..2c5838ffd3 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3398,6 +3398,7 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): return "{}/{}".format(display, viewer) return "{} ({})".format(viewer, display) + def get_file_with_name_and_hashes(original_path, name): """Function to get the ranmed filename with frame hashes From 7dd64ff210078716b80271ab33e81a4ba7266993 Mon Sep 17 00:00:00 2001 From: Kayla Date: Sun, 24 Sep 2023 13:07:03 +0800 Subject: [PATCH 0198/1224] 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 0199/1224] 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 0200/1224] 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 5f7f4f08d1cf1ac38c4016e3c4d4bb2211b73767 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 14:23:17 +0800 Subject: [PATCH 0201/1224] fix the slate in --- 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 b7927738d6..f587d109c1 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -591,6 +591,11 @@ class ExporterReview(object): # get first and last frame self.first_frame = min(self.collection.indexes) self.last_frame = max(self.collection.indexes) + + first = self.instance.data.get("frameStartHandle", None) + if first: + if first > self.first_frame: + self.first_frame = first else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." From 4c854600cbbc3a7d4d1475893ab3f900d2c92605 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 25 Sep 2023 10:07:37 +0100 Subject: [PATCH 0202/1224] Renamed family to yeticacheUE --- openpype/hosts/maya/plugins/create/create_unreal_yeticache.py | 2 +- openpype/hosts/maya/plugins/publish/collect_yeti_cache.py | 2 +- openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py | 2 +- openpype/hosts/unreal/plugins/load/load_yeticache.py | 2 +- openpype/plugins/publish/collect_resources_path.py | 2 +- openpype/plugins/publish/integrate.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py index 8ff3dccea2..defa6ed6d9 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py @@ -10,7 +10,7 @@ class CreateYetiCache(plugin.MayaCreator): identifier = "io.openpype.creators.maya.unrealyeticache" label = "Unreal Yeti Cache" - family = "ue_yeticache" + family = "yeticacheUE" icon = "pagelines" def get_instance_attr_defs(self): diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index 426e330f89..7a1516997a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -39,7 +39,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.45 label = "Collect Yeti Cache" - families = ["yetiRig", "yeticache", "ue_yeticache"] + families = ["yetiRig", "yeticache", "yeticacheUE"] hosts = ["maya"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py index 816e125c33..e72146a871 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -13,7 +13,7 @@ class ExtractYetiCache(publish.Extractor): label = "Extract Yeti Cache" hosts = ["maya"] - families = ["ue_yeticache"] + families = ["yeticacheUE"] def process(self, instance): diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py index 59dbdbee31..328f92d020 100644 --- a/openpype/hosts/unreal/plugins/load/load_yeticache.py +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -14,7 +14,7 @@ import unreal # noqa class YetiLoader(plugin.Loader): """Load Yeti Cache""" - families = ["ue_yeticache"] + families = ["yeticacheUE"] label = "Import Yeti" representations = ["abc"] icon = "pagelines" diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index 6840509f79..57a612c5ae 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -63,7 +63,7 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "staticMesh", "skeletalMesh", "xgen", - "ue_yeticache" + "yeticacheUE" ] def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 24fbc0d8e7..ce24dad1b5 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -140,7 +140,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "online", "uasset", "blendScene", - "ue_yeticache" + "yeticacheUE" ] default_template_name = "publish" From 51875f592e95a30f84563835fa63d7b4c31e3dc9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 11:21:02 +0200 Subject: [PATCH 0203/1224] instance data keys should not be optional --- openpype/hosts/nuke/api/plugin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 1e318e17cf..4ce314f6fb 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -580,8 +580,9 @@ class ExporterReview(object): def get_file_info(self): if self.collection: # get path - self.fname = os.path.basename(self.collection.format( - "{head}{padding}{tail}")) + self.fname = os.path.basename( + self.collection.format("{head}{padding}{tail}") + ) self.fhead = self.collection.format("{head}") # get first and last frame @@ -590,8 +591,8 @@ class ExporterReview(object): else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." - self.first_frame = self.instance.data.get("frameStartHandle", None) - self.last_frame = self.instance.data.get("frameEndHandle", None) + self.first_frame = self.instance.data["frameStartHandle"] + self.last_frame = self.instance.data["frameEndHandle"] if "#" in self.fhead: self.fhead = self.fhead.replace("#", "")[:-1] From 4289997553340bccb7bb682d6c14ac4b51ca6939 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 11:21:28 +0200 Subject: [PATCH 0204/1224] slate frame exception - in case it already exists --- openpype/hosts/nuke/api/plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 4ce314f6fb..6d5d7eddf1 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -588,6 +588,12 @@ class ExporterReview(object): # get first and last frame self.first_frame = min(self.collection.indexes) self.last_frame = max(self.collection.indexes) + + # make sure slate frame is not included + frame_start_handle = self.instance.data["frameStartHandle"] + if frame_start_handle > self.first_frame: + self.first_frame = frame_start_handle + else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." From 131a55b32c880d4d9983d529aea97ae926aed183 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 11:22:58 +0200 Subject: [PATCH 0205/1224] :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 50bae2d049092f7f43e9ef619f1b65ee0c14db25 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 25 Sep 2023 10:34:53 +0100 Subject: [PATCH 0206/1224] Check if Groom plugin is active when loading in Unreal --- .../unreal/plugins/load/load_yeticache.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py index 328f92d020..92f080d7c5 100644 --- a/openpype/hosts/unreal/plugins/load/load_yeticache.py +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Loader for Yeti Cache.""" import os +import json from openpype.pipeline import ( get_representation_path, @@ -36,6 +37,28 @@ class YetiLoader(plugin.Loader): return task + @staticmethod + def is_groom_module_active(): + """ + Check if Groom plugin is active. + + This is a workaround, because the Unreal python API don't have + any method to check if plugin is active. + """ + prj_file = unreal.Paths.get_project_file_path() + + with open(prj_file, "r") as fp: + data = json.load(fp) + + plugins = data.get("Plugins") + + if not plugins: + return False + + plugin_names = [p.get("Name") for p in plugins] + + return "HairStrands" in plugin_names + def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. @@ -58,6 +81,10 @@ class YetiLoader(plugin.Loader): list(str): list of container content """ + # Check if Groom plugin is active + if not self.is_groom_module_active(): + raise RuntimeError("Groom plugin is not activated.") + # Create directory for asset and Ayon container root = "/Game/Ayon/Assets" asset = context.get('asset').get('name') From 2a9d90d742f38f8abd5bb0deee224d2f469ac9c1 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 11:38:23 +0200 Subject: [PATCH 0207/1224] :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 5cf8fdbb6ca60327634ed5b8110a395410d5be51 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 25 Sep 2023 10:45:28 +0100 Subject: [PATCH 0208/1224] Do not use version in the import folder --- .../plugins/create/create_unreal_yeticache.py | 2 +- .../unreal/plugins/load/load_yeticache.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py index defa6ed6d9..c9f9cd9ba8 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py @@ -9,7 +9,7 @@ class CreateYetiCache(plugin.MayaCreator): """Output for procedural plugin nodes of Yeti """ identifier = "io.openpype.creators.maya.unrealyeticache" - label = "Unreal Yeti Cache" + label = "Unreal - Yeti Cache" family = "yeticacheUE" icon = "pagelines" diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py index 92f080d7c5..bb6c2d78cc 100644 --- a/openpype/hosts/unreal/plugins/load/load_yeticache.py +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -90,18 +90,20 @@ class YetiLoader(plugin.Loader): asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" - version = context.get('version') - # Check if version is hero version and use different name - if not version.get("name") and version.get('type') == "hero_version": - name_version = f"{name}_hero" - else: - name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name_version}", suffix="") + f"{root}/{asset}/{name}", suffix="") + + unique_number = 1 + while unreal.EditorAssetLibrary.does_directory_exist( + f"{asset_dir}_{unique_number:02}" + ): + unique_number += 1 + + asset_dir = f"{asset_dir}_{unique_number:02}" + container_name = f"{container_name}_{unique_number:02}{suffix}" - container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): unreal.EditorAssetLibrary.make_directory(asset_dir) From 24e6b756ec44976667500129df08cb3b7fae495b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 25 Sep 2023 10:46:33 +0100 Subject: [PATCH 0209/1224] Hound fix --- openpype/hosts/unreal/plugins/load/load_yeticache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py index bb6c2d78cc..22f5029bac 100644 --- a/openpype/hosts/unreal/plugins/load/load_yeticache.py +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -104,7 +104,6 @@ class YetiLoader(plugin.Loader): asset_dir = f"{asset_dir}_{unique_number:02}" container_name = f"{container_name}_{unique_number:02}{suffix}" - if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): unreal.EditorAssetLibrary.make_directory(asset_dir) From 41e81ef7a87faced226f2bcc6b904e58de5f78e0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 18:03:22 +0800 Subject: [PATCH 0210/1224] rename the function and the elaborate the docstring --- openpype/hosts/nuke/api/lib.py | 6 ++++-- openpype/hosts/nuke/api/plugin.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 2c5838ffd3..d95839bb8d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3399,8 +3399,10 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): return "{} ({})".format(viewer, display) -def get_file_with_name_and_hashes(original_path, name): - """Function to get the ranmed filename with frame hashes +def get_head_filename_without_hashes(original_path, name): + """Function to get the ranmed 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 Args: original_path (str): the filename with frame hashes diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 97e7c3ad8c..1550a60f32 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_file_with_name_and_hashes + get_head_filename_without_hashes ) from .pipeline import ( list_instances, @@ -813,7 +813,7 @@ class ExporterReviewMov(ExporterReview): self.file = self.fhead + self.name + ".{}".format(self.ext) if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: - filename = get_file_with_name_and_hashes( + filename = get_head_filename_without_hashes( self.path_in, self.name) self.file = filename if ".{}".format(self.ext) not in self.file: From 22ce181f2de6b1ed001a135442c1884a71882599 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 18:24:48 +0800 Subject: [PATCH 0211/1224] make sure the deprecated setting used when it enabled while the current setting is used when the deprecrated setting diabled --- openpype/settings/ayon_settings.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index a66e1b6ec6..b43e0b7c5f 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -754,10 +754,9 @@ def _convert_nuke_project_settings(ayon_settings, output): current_review_settings = ( ayon_publish["ExtractReviewDataBakingStreams"] ) - if deprecrated_review_settings["outputs"] == ( - current_review_settings["outputs"] - ): - outputs_settings = current_review_settings["outputs"] + if not deprecrated_review_settings["enabled"]: + if current_review_settings["enabled"]: + outputs_settings = current_review_settings["outputs"] else: outputs_settings = deprecrated_review_settings["outputs"] From 3da4bac77db45edff25df7e4958a301ff5775cbc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 18:26:16 +0800 Subject: [PATCH 0212/1224] typo in lib --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index d95839bb8d..3617133d2b 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3400,7 +3400,7 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): def get_head_filename_without_hashes(original_path, name): - """Function to get the ranmed head filename without frame hashes + """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 From e0ce8013f46a1cf1c46774f43697930c09a7fd6a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 25 Sep 2023 11:32:44 +0100 Subject: [PATCH 0213/1224] Use f-string --- openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py index e72146a871..963285093e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -39,7 +39,7 @@ class ExtractYetiCache(publish.Extractor): kwargs.update({"samples": samples}) self.log.debug(f"Writing out cache {start_frame} - {end_frame}") - filename = "{0}.abc".format(instance.name) + filename = f"{instance.name}.abc" path = os.path.join(dirname, filename) cmds.pgYetiCommand(yeti_nodes, writeAlembic=path, From 54e9efba95192b3f34ab03f50b992535ee56e72c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 12:51:38 +0200 Subject: [PATCH 0214/1224] :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 0e594b39cdf836b8ce41637efba825a3863deae4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 12:52:51 +0200 Subject: [PATCH 0215/1224] no need to check `config_data` exits in this section of code. --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 44cff34c67..a77dc5763a 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -177,7 +177,7 @@ def get_colorspace_name_from_filepath( return None # validate matching colorspace with config - if validate and config_data: + if validate: validate_imageio_colorspace_in_config( config_data["path"], colorspace_name) From 5d1f2b0d9ed3ff44f1f924870e9a829c9ee4ee12 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 13:01:43 +0200 Subject: [PATCH 0216/1224] typo --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a77dc5763a..a67457b1bf 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -328,7 +328,7 @@ def parse_colorspace_from_filepath( str: name of colorspace """ def _get_colorspace_match_regex(colorspaces): - """Return a regex patter + """Return a regex pattern Allows to search a colorspace match in a filename From 8b76238b2d8199e2af541365e6ef074ef1c95825 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 19:25:44 +0800 Subject: [PATCH 0217/1224] fixing get_head_filename_without_hashes not being able to get multiple hashes & some strip fix --- openpype/hosts/nuke/api/lib.py | 8 ++++++-- openpype/hosts/nuke/api/plugin.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 3617133d2b..29e7c88c71 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3406,15 +3406,19 @@ def get_head_filename_without_hashes(original_path, name): Args: original_path (str): the filename with frame hashes + e.g. "renderAssetMain.####.exr" name (str): the name of the tags + e.g. "baking" Returns: filename: the renamed filename with the tag + e.g. "renderAssetMain.baking.####.exr" """ filename = os.path.basename(original_path) fhead = filename.split(".")[0] - if "#" in fhead: - fhead = fhead.replace("#", "")[:-1] + tmp_fhead = re.sub("\d", "#", fhead) + if "#" in tmp_fhead: + fhead = tmp_fhead.replace("#", "").rstrip(".") new_fhead = "{}.{}".format(fhead, name) filename = filename.replace(fhead, new_fhead) return filename diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 1550a60f32..ca31068943 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -817,7 +817,7 @@ class ExporterReviewMov(ExporterReview): self.path_in, self.name) self.file = filename if ".{}".format(self.ext) not in self.file: - original_ext = filename.split(".")[-1] + original_ext = os.path.splitext(filename)[-1].strip(".") # noqa self.file = filename.replace(original_ext, ext) self.path = os.path.join( From e2509a9447c5900f4c469a2c81e89c9a79a2524e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 19:29:35 +0800 Subject: [PATCH 0218/1224] hound --- openpype/hosts/nuke/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 29e7c88c71..7b1aaa8fe0 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3415,8 +3415,8 @@ def get_head_filename_without_hashes(original_path, name): e.g. "renderAssetMain.baking.####.exr" """ filename = os.path.basename(original_path) - fhead = filename.split(".")[0] - tmp_fhead = re.sub("\d", "#", fhead) + fhead = os.path.splitext(filename)[0].strip(".") + tmp_fhead = re.sub(r"\d", "#", fhead) if "#" in tmp_fhead: fhead = tmp_fhead.replace("#", "").rstrip(".") new_fhead = "{}.{}".format(fhead, name) From c77faa4be4f0db9c2d6f8d083bae70c41f0fc776 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 25 Sep 2023 13:42:22 +0200 Subject: [PATCH 0219/1224] Fix audio node source in - source out on updating audio version --- .../hosts/maya/plugins/load/load_audio.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index ecf98303d2..a8a878d49d 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -1,12 +1,6 @@ from maya import cmds, mel -from openpype.client import ( - get_asset_by_id, - get_subset_by_id, - get_version_by_id, -) from openpype.pipeline import ( - get_current_project_name, load, get_representation_path, ) @@ -67,7 +61,26 @@ class AudioLoader(load.LoaderPlugin): activate_sound = current_sound == audio_node path = get_representation_path(representation) - cmds.setAttr("{}.filename".format(audio_node), path, type="string") + + cmds.sound( + audio_node, + edit=True, + file=path + ) + + # The source start + end does not automatically update itself to the + # length of thew new audio file, even though maya does do that when + # when creating a new audio node. So to update we compute it manually. + # This would however override any source start and source end a user + # might have done on the original audio node after load. + audio_frame_count = cmds.getAttr("{}.frameCount".format(audio_node)) + audio_sample_rate = cmds.getAttr("{}.sampleRate".format(audio_node)) + duration_in_seconds = audio_frame_count / audio_sample_rate + fps = mel.eval('currentTimeUnitToFPS()') # workfile FPS + source_start = 0 + source_end = (duration_in_seconds * fps) + cmds.setAttr("{}.sourceStart".format(audio_node), source_start) + cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) if activate_sound: # maya by default deactivates it from timeline on file change @@ -84,26 +97,6 @@ class AudioLoader(load.LoaderPlugin): type="string" ) - # Set frame range. - project_name = get_current_project_name() - version = get_version_by_id( - project_name, representation["parent"], fields=["parent"] - ) - subset = get_subset_by_id( - project_name, version["parent"], fields=["parent"] - ) - asset = get_asset_by_id( - project_name, - subset["parent"], - fields=["parent", "data.frameStart", "data.frameEnd"] - ) - - source_start = 1 - asset["data"]["frameStart"] - source_end = asset["data"]["frameEnd"] - - cmds.setAttr("{}.sourceStart".format(audio_node), source_start) - cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) - def switch(self, container, representation): self.update(container, representation) From 905038f6e86ef5c8116f852cab7547d7ea8d6ac8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 25 Sep 2023 13:42:39 +0200 Subject: [PATCH 0220/1224] Fix typo --- openpype/hosts/maya/plugins/load/load_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index a8a878d49d..90cadb31b1 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -70,7 +70,7 @@ class AudioLoader(load.LoaderPlugin): # The source start + end does not automatically update itself to the # length of thew new audio file, even though maya does do that when - # when creating a new audio node. So to update we compute it manually. + # creating a new audio node. So to update we compute it manually. # This would however override any source start and source end a user # might have done on the original audio node after load. audio_frame_count = cmds.getAttr("{}.frameCount".format(audio_node)) From 49af8c21ecc4d88153f0f438873528948409fa8f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 25 Sep 2023 14:03:04 +0200 Subject: [PATCH 0221/1224] :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 0222/1224] :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 b0ae4257f95f56a4d5510a48516945e5bfb4edbb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 15:23:02 +0200 Subject: [PATCH 0223/1224] missing `allowed_exts` issue and unit tests fix --- openpype/pipeline/colorspace.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a67457b1bf..2800050496 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -27,6 +27,9 @@ class CachedData: has_compatible_ocio_package = None config_version_data = {} ocio_config_colorspaces = {} + allowed_exts = { + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + } class DeprecatedWarning(DeprecationWarning): @@ -361,7 +364,7 @@ def parse_colorspace_from_filepath( # match colorspace from filepath regex_pattern = _get_colorspace_match_regex( - colorspaces + underscored_colorspaces.keys()) + list(colorspaces) + list(underscored_colorspaces)) match = regex_pattern.search(filepath) colorspace = match.group(0) if match else None From fd6c6f3b3cbd6c4d820f1725c117ef43e9cde89d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 25 Sep 2023 21:59:03 +0800 Subject: [PATCH 0224/1224] add docstrings for the functions in tycache families --- .../max/plugins/publish/extract_tycache.py | 19 ++++++++++++++++++ .../plugins/publish/validate_tyflow_data.py | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index e98fad5c2b..c3e9489d43 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -67,6 +67,25 @@ class ExtractTyCache(publish.Extractor): self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") def get_file(self, filepath, start_frame, end_frame): + """Get file names for tyFlow in tyCache format. + + Set the filenames accordingly to the tyCache file + naming extension(.tyc) for the publishing purpose + + Actual File Output from tyFlow in tyCache format: + _.tyc + + e.g. tyFlow_cloth_CCCS_blobbyFill_001_00004.tyc + + Args: + fileapth (str): Output directory. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + filenames(list): list of filenames + + """ filenames = [] filename = os.path.basename(filepath) orig_name, _ = os.path.splitext(filename) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index 4574950495..c0a6d23022 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -34,6 +34,16 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): raise PublishValidationError(f"{report}") def get_tyflow_object(self, instance): + """Get the nodes which are not tyFlow object(s) + and editable mesh(es) + + Args: + instance (str): instance node + + Returns: + invalid(list): list of invalid nodes which are not + tyFlow object(s) and editable mesh(es). + """ invalid = [] container = instance.data["instance_node"] self.log.info(f"Validating tyFlow container for {container}") @@ -51,6 +61,16 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): return invalid def get_tyflow_operator(self, instance): + """_summary_ + + Args: + instance (str): instance node + + Returns: + invalid(list): list of invalid nodes which do + not consist of Export Particle Operators as parts + of the node connections + """ invalid = [] container = instance.data["instance_node"] self.log.info(f"Validating tyFlow object for {container}") From 6a4ab981ad2a9f5b6f9d3225a260bb43e7d4ac9b Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 25 Sep 2023 23:00:25 +0800 Subject: [PATCH 0225/1224] 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 92bc7c12f9241eb4a3c40c7c543ac84c30e78a92 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 17:23:34 +0200 Subject: [PATCH 0226/1224] fixing missing assetEntity --- openpype/plugins/publish/collect_sequence_frame_data.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 6c2bfbf358..5fecc65446 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -28,6 +28,10 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") + parent_entity = ( + instance.context.data.get("assetEntity") + or instance.context.data["projectEntity"] + ) if repres: first_repre = repres[0] if "ext" not in first_repre: @@ -52,5 +56,5 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, - "fps": instance.context.data["assetEntity"]["data"]["fps"] + "fps": parent_entity["data"]["fps"] } From a73ba98209aa18fe6174a135e986962aac3d2ab0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 25 Sep 2023 17:29:14 +0200 Subject: [PATCH 0227/1224] assetEntity is not on context data --- openpype/plugins/publish/collect_sequence_frame_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 5fecc65446..1c456563e6 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -29,7 +29,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") parent_entity = ( - instance.context.data.get("assetEntity") + instance.data.get("assetEntity") or instance.context.data["projectEntity"] ) if repres: From 1bd07bd15b1bf238e186b23b7d112e9fe4637737 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 25 Sep 2023 18:01:24 +0200 Subject: [PATCH 0228/1224] OP-6874 - remove trailing underscore in subset name (#5647) If {layer} placeholder is at the end of subset name template and not used (for example in auto_image where separating it by layer doesn't make any sense) trailing '_' was kept. This updates cleaning logic and extracts it as it might be similar in regular `image` instance. --- openpype/hosts/photoshop/lib.py | 17 +++++++++++++ .../plugins/create/create_flatten_image.py | 14 ++--------- .../photoshop/plugins/create/create_image.py | 3 ++- .../unit/openpype/hosts/photoshop/test_lib.py | 25 +++++++++++++++++++ 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 tests/unit/openpype/hosts/photoshop/test_lib.py diff --git a/openpype/hosts/photoshop/lib.py b/openpype/hosts/photoshop/lib.py index ae7a33b7b6..9f603a70d2 100644 --- a/openpype/hosts/photoshop/lib.py +++ b/openpype/hosts/photoshop/lib.py @@ -1,5 +1,8 @@ +import re + import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name +from openpype.lib import prepare_template_data from openpype.pipeline import ( AutoCreator, CreatedInstance @@ -78,3 +81,17 @@ class PSAutoCreator(AutoCreator): existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name + + +def clean_subset_name(subset_name): + """Clean all variants leftover {layer} from subset name.""" + dynamic_data = prepare_template_data({"layer": "{layer}"}) + for value in dynamic_data.values(): + if value in subset_name: + subset_name = (subset_name.replace(value, "") + .replace("__", "_") + .replace("..", ".")) + # clean trailing separator as Main_ + pattern = r'[\W_]+$' + replacement = '' + return re.sub(pattern, replacement, subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index e4229788bd..afde77fdb4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -2,7 +2,7 @@ from openpype.pipeline import CreatedInstance from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api -from openpype.hosts.photoshop.lib import PSAutoCreator +from openpype.hosts.photoshop.lib import PSAutoCreator, clean_subset_name from openpype.pipeline.create import get_subset_name from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name @@ -129,14 +129,4 @@ class AutoImageCreator(PSAutoCreator): self.family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data ) - return self._clean_subset_name(subset_name) - - def _clean_subset_name(self, subset_name): - """Clean all variants leftover {layer} from subset name.""" - dynamic_data = prepare_template_data({"layer": "{layer}"}) - for value in dynamic_data.values(): - if value in subset_name: - return (subset_name.replace(value, "") - .replace("__", "_") - .replace("..", ".")) - return subset_name + return clean_subset_name(subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index af20d456e0..4f2e90886a 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -10,6 +10,7 @@ from openpype.pipeline import ( from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances +from openpype.hosts.photoshop.lib import clean_subset_name class ImageCreator(Creator): @@ -88,6 +89,7 @@ class ImageCreator(Creator): layer_fill = prepare_template_data({"layer": layer_name}) subset_name = subset_name.format(**layer_fill) + subset_name = clean_subset_name(subset_name) if group.long_name: for directory in group.long_name[::-1]: @@ -184,7 +186,6 @@ class ImageCreator(Creator): self.mark_for_review = plugin_settings["mark_for_review"] self.enabled = plugin_settings["enabled"] - def get_detail_description(self): return """Creator for Image instances diff --git a/tests/unit/openpype/hosts/photoshop/test_lib.py b/tests/unit/openpype/hosts/photoshop/test_lib.py new file mode 100644 index 0000000000..ad4feb42ae --- /dev/null +++ b/tests/unit/openpype/hosts/photoshop/test_lib.py @@ -0,0 +1,25 @@ +import pytest + +from openpype.hosts.photoshop.lib import clean_subset_name + +""" +Tests cleanup of unused layer placeholder ({layer}) from subset name. +Layer differentiation might be desired in subset name, but in some cases it +might be used (in `auto_image` - only single image without layer diff., +single image instance created without toggled use of subset name etc.) +""" + + +def test_no_layer_placeholder(): + clean_subset = clean_subset_name("imageMain") + assert "imageMain" == clean_subset + + +@pytest.mark.parametrize("subset_name", + ["imageMain{Layer}", + "imageMain_{layer}", # trailing _ + "image{Layer}Main", + "image{LAYER}Main"]) +def test_not_used_layer_placeholder(subset_name): + clean_subset = clean_subset_name(subset_name) + assert "imageMain" == clean_subset From 0595afe8a3240747cd852e3fc4073f58382ccd86 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 26 Sep 2023 00:34:44 +0800 Subject: [PATCH 0229/1224] add % check on the fhead in the lib.py --- openpype/hosts/nuke/api/lib.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index af07092daf..8790794fcd 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3432,19 +3432,27 @@ def get_head_filename_without_hashes(original_path, name): Args: original_path (str): the filename with frame hashes - e.g. "renderAssetMain.####.exr" + e.g. "renderCompositingMain.####.exr" name (str): the name of the tags e.g. "baking" Returns: filename: the renamed filename with the tag - e.g. "renderAssetMain.baking.####.exr" + e.g. "renderCompositingMain.baking.####.exr" """ filename = os.path.basename(original_path) fhead = os.path.splitext(filename)[0].strip(".") - tmp_fhead = re.sub(r"\d", "#", fhead) - if "#" in tmp_fhead: - fhead = tmp_fhead.replace("#", "").rstrip(".") + if "#" in fhead: + fhead = re.sub("#+", "", fhead).rstrip(".") + elif "%" in fhead: + # use regex to convert %04d to {:0>4} + padding = re.search("%(\\d)+d", fhead) + padding = padding.group(1) if padding else 1 + fhead = re.sub( + "%.*d", + "{{:0>{}}}".format(padding), + fhead + ).rstip(".") new_fhead = "{}.{}".format(fhead, name) filename = filename.replace(fhead, new_fhead) return filename From 65bfe023c7d2b8a571185a49e78ef7b775d30fc9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 26 Sep 2023 00:41:04 +0800 Subject: [PATCH 0230/1224] transform the files with frame hashes to the list of filenames when publishing --- openpype/hosts/nuke/api/lib.py | 24 ++++++++++++++++++++++++ openpype/hosts/nuke/api/plugin.py | 7 ++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 8790794fcd..9e41dbe8bc 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3456,3 +3456,27 @@ def get_head_filename_without_hashes(original_path, name): new_fhead = "{}.{}".format(fhead, name) filename = filename.replace(fhead, new_fhead) return filename + + +def get_filenames_without_hash(filename, frame_start, frame_end): + """Get filenames without frame hash + i.e. "renderCompositingMain.baking.0001.exr" + + Args: + filename (str): filename with frame hash + frame_start (str): start of the frame + frame_end (str): end of the frame + + Returns: + filenames(list): list of filename + """ + filenames = [] + for frame in range(int(frame_start), (int(frame_end) + 1)): + if "#" in filename: + # use regex to convert #### to {:0>4} + def replace(match): + return "{{:0>{}}}".format(len(match.group())) + filename_without_hashes = re.sub("#+", replace, filename) + new_filename = filename_without_hashes.format(frame) + filenames.append(new_filename) + return filenames diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index ca31068943..348a0b5d76 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -39,7 +39,8 @@ from .lib import ( get_view_process_node, get_viewer_config_from_string, deprecated, - get_head_filename_without_hashes + get_head_filename_without_hashes, + get_filenames_without_hash ) from .pipeline import ( list_instances, @@ -638,6 +639,10 @@ class ExporterReview(object): "frameStart": self.first_frame, "frameEnd": self.last_frame, }) + if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: + filenames = get_filenames_without_hash( + self.file, self.first_frame, self.last_frame) + repre["files"] = filenames if self.multiple_presets: repre["outputName"] = self.name From a352a6468022cf46febfeaf19c19ba93d5e0d59c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 25 Sep 2023 20:16:56 +0300 Subject: [PATCH 0231/1224] 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 0232/1224] 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 0233/1224] 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 0234/1224] 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 0235/1224] 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 0236/1224] 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 4b1c9077e6c0f109c3315ddc88dd74f09a301bfe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Sep 2023 13:42:01 +0200 Subject: [PATCH 0237/1224] fix workfiles tool save button (#5653) --- openpype/tools/ayon_workfiles/models/workfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py index 316d8b2a16..4d989ed22c 100644 --- a/openpype/tools/ayon_workfiles/models/workfiles.py +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -48,7 +48,7 @@ def get_task_template_data(project_entity, task): return {} short_name = None task_type_name = task["taskType"] - for task_type_info in project_entity["config"]["taskTypes"]: + for task_type_info in project_entity["taskTypes"]: if task_type_info["name"] == task_type_name: short_name = task_type_info["shortName"] break From f8463f6e9e30e1e96a50239c9a6e96cc3dbc2961 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 26 Sep 2023 15:11:00 +0200 Subject: [PATCH 0238/1224] :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 0239/1224] :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 0240/1224] 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 0241/1224] 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 40755fce119f388efa85e4cab5c94da749b54dbf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 26 Sep 2023 18:56:01 +0200 Subject: [PATCH 0242/1224] Increase timout for deadline test (#5654) DL picks up jobs quite slow, so bump up delay. --- tests/integration/hosts/maya/test_deadline_publish_in_maya.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py index c5bf526f52..9332177944 100644 --- a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py @@ -32,7 +32,7 @@ class TestDeadlinePublishInMaya(MayaDeadlinePublishTestClass): # keep empty to locate latest installed variant or explicit APP_VARIANT = "" - TIMEOUT = 120 # publish timeout + TIMEOUT = 180 # publish timeout def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" From fd8daebed9bbb579c13f193253c00a5d7cf22005 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 26 Sep 2023 20:25:51 +0300 Subject: [PATCH 0243/1224] 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 0244/1224] 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 16bcbc155867c0daef9c990484035c6ba0f16ec2 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 27 Sep 2023 03:24:58 +0000 Subject: [PATCH 0245/1224] [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 d1ebde3d04..c8ae6dffd8 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.1-nightly.2" +__version__ = "3.17.1-nightly.3" From 3ddfb13e2aae05e4df2b3a6da7900600d8649d1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 Sep 2023 03:25:45 +0000 Subject: [PATCH 0246/1224] 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 87d904fc84..a2edd28f5b 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.1-nightly.3 - 3.17.1-nightly.2 - 3.17.1-nightly.1 - 3.17.0 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.6 - 3.14.10-nightly.5 - 3.14.10-nightly.4 - - 3.14.10-nightly.3 validations: required: true - type: dropdown From 4ae8e7fa774e06ebc690998f2dd7d623a9e8c044 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Sep 2023 11:39:15 +0200 Subject: [PATCH 0247/1224] removing project entity redundancy --- .../plugins/publish/collect_sequence_frame_data.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 1c456563e6..33ff3281a2 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -28,10 +28,12 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") - parent_entity = ( - instance.data.get("assetEntity") - or instance.context.data["projectEntity"] - ) + parent_entity = instance.data.get("assetEntity") + + if not parent_entity: + self.log.warning("Cannot find parent entity data") + return + if repres: first_repre = repres[0] if "ext" not in first_repre: @@ -40,7 +42,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return files = first_repre["files"] - collections, remainder = clique.assemble(files) + collections, _ = clique.assemble(files) if not collections: # No sequences detected and we can't retrieve # frame range From f15dcb30b87daf23b3c0a087237cf50e7b26ddf8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 12:54:02 +0300 Subject: [PATCH 0248/1224] 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 fbafc420aaaced3501be9e2b24f8025c85809c8c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Sep 2023 12:28:42 +0200 Subject: [PATCH 0249/1224] reverting enhancing UX of sequence or asset frame data collection --- ...> collect_frame_data_from_asset_entity.py} | 23 +++++++------------ .../publish/collect_sequence_frame_data.py | 21 +++++++++-------- 2 files changed, 19 insertions(+), 25 deletions(-) rename openpype/hosts/traypublisher/plugins/publish/{collect_missing_frame_range_asset_entity.py => collect_frame_data_from_asset_entity.py} (51%) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py similarity index 51% rename from openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py rename to openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index 72379ea4e1..f2e24d88eb 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -2,18 +2,18 @@ import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin -class CollectMissingFrameDataFromAssetEntity( +class CollectFrameDataFromAssetEntity( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): - """Collect Missing Frame Range data From Asset Entity + """Collect Frame Data From AssetEntity found in context Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Missing Frame Data From Asset Entity" + label = "Collect Frame Data From Asset" families = ["plate", "pointcache", "vdbcache", "online", "render"] @@ -23,7 +23,9 @@ class CollectMissingFrameDataFromAssetEntity( def process(self, instance): if not self.is_active(instance.data): return - missing_keys = [] + + asset_data = instance.data["assetEntity"]["data"] + for key in ( "fps", "frameStart", @@ -31,14 +33,5 @@ class CollectMissingFrameDataFromAssetEntity( "handleStart", "handleEnd" ): - if key not in instance.data: - missing_keys.append(key) - keys_set = [] - for key in missing_keys: - asset_data = instance.data["assetEntity"]["data"] - if key in asset_data: - instance.data[key] = asset_data[key] - keys_set.append(key) - if keys_set: - self.log.debug(f"Frame range data {keys_set} " - "has been collected from asset entity.") + instance.data[key] = asset_data[key] + self.log.debug(f"Collected Frame range data '{key}':{asset_data[key]} ") diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 33ff3281a2..d8ad5d0a21 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -9,7 +9,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): start and end frame respectively """ - order = pyblish.api.CollectorOrder + 0.2 + order = pyblish.api.CollectorOrder + 0.490 label = "Collect Sequence Frame Data" families = ["plate", "pointcache", "vdbcache", "online", @@ -18,21 +18,22 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): def process(self, instance): frame_data = self.get_frame_data_from_repre_sequence(instance) + if not frame_data: # if no dict data skip collecting the frame range data return + for key, value in frame_data.items(): - if key not in instance.data: - instance.data[key] = value - self.log.debug(f"Collected Frame range data '{key}':{value} ") + instance.data[key] = value + self.log.debug(f"Collected Frame range data '{key}':{value} ") + + test_keys = {key: value for key, value in instance.data.items() if key in frame_data} + self.log.debug(f"Final Instance frame data: {test_keys}") + def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") - parent_entity = instance.data.get("assetEntity") - - if not parent_entity: - self.log.warning("Cannot find parent entity data") - return + asset_data = instance.data["assetEntity"]["data"] if repres: first_repre = repres[0] @@ -58,5 +59,5 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, - "fps": parent_entity["data"]["fps"] + "fps": asset_data["fps"] } From 5b1cbfaa6743c6bd4f9b6be4e86b3a0854dbb3c9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Sep 2023 12:35:36 +0200 Subject: [PATCH 0250/1224] removing debug prints --- .../plugins/publish/collect_frame_data_from_asset_entity.py | 3 ++- openpype/plugins/publish/collect_sequence_frame_data.py | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index f2e24d88eb..5ba84bc05b 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -34,4 +34,5 @@ class CollectFrameDataFromAssetEntity( "handleEnd" ): instance.data[key] = asset_data[key] - self.log.debug(f"Collected Frame range data '{key}':{asset_data[key]} ") + self.log.debug( + f"Collected Frame range data '{key}':{asset_data[key]} ") diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index d8ad5d0a21..f9ac869ec3 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -27,9 +27,6 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): instance.data[key] = value self.log.debug(f"Collected Frame range data '{key}':{value} ") - test_keys = {key: value for key, value in instance.data.items() if key in frame_data} - self.log.debug(f"Final Instance frame data: {test_keys}") - def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") From e90d227a234fec278b214c3ed4086350403eda80 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Sep 2023 13:37:01 +0200 Subject: [PATCH 0251/1224] reverting the functionality - sequencial original frame data should be optional plugin - sequential data are added if activated - asset data frame data are not optional anymore and are added only if missing --- .../collect_frame_data_from_asset_entity.py | 27 +++++++++---------- .../publish/collect_sequence_frame_data.py | 18 ++++++++++--- .../project_settings/traypublisher.json | 4 +-- .../schema_project_traypublisher.json | 4 +-- 4 files changed, 31 insertions(+), 22 deletions(-) rename openpype/{ => hosts/traypublisher}/plugins/publish/collect_sequence_frame_data.py (82%) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index 5ba84bc05b..2950076cd0 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -1,11 +1,7 @@ import pyblish.api -from openpype.pipeline import OptionalPyblishPluginMixin -class CollectFrameDataFromAssetEntity( - pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin -): +class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin): """Collect Frame Data From AssetEntity found in context Frame range data will only be collected if the keys @@ -18,14 +14,9 @@ class CollectFrameDataFromAssetEntity( "vdbcache", "online", "render"] hosts = ["traypublisher"] - optional = True def process(self, instance): - if not self.is_active(instance.data): - return - - asset_data = instance.data["assetEntity"]["data"] - + missing_keys = [] for key in ( "fps", "frameStart", @@ -33,6 +24,14 @@ class CollectFrameDataFromAssetEntity( "handleStart", "handleEnd" ): - instance.data[key] = asset_data[key] - self.log.debug( - f"Collected Frame range data '{key}':{asset_data[key]} ") + if key not in instance.data: + missing_keys.append(key) + keys_set = [] + for key in missing_keys: + asset_data = instance.data["assetEntity"]["data"] + if key in asset_data: + instance.data[key] = asset_data[key] + keys_set.append(key) + if keys_set: + self.log.debug(f"Frame range data {keys_set} " + "has been collected from asset entity.") diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py similarity index 82% rename from openpype/plugins/publish/collect_sequence_frame_data.py rename to openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py index f9ac869ec3..db70d4fe0a 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py @@ -1,22 +1,32 @@ import pyblish.api import clique +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectSequenceFrameData( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Collect Original Sequence Frame Data -class CollectSequenceFrameData(pyblish.api.InstancePlugin): - """Collect Sequence Frame Data If the representation includes files with frame numbers, then set `frameStart` and `frameEnd` for the instance to the start and end frame respectively """ - order = pyblish.api.CollectorOrder + 0.490 - label = "Collect Sequence Frame Data" + order = pyblish.api.CollectorOrder + 0.4905 + label = "Collect Original Sequence Frame Data" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] + optional = True def process(self, instance): + if not self.is_active(instance.data): + return + frame_data = self.get_frame_data_from_repre_sequence(instance) if not frame_data: diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 7f7b7d1452..e13de11414 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -346,10 +346,10 @@ } }, "publish": { - "CollectFrameDataFromAssetEntity": { + "CollectSequenceFrameData": { "enabled": true, "optional": true, - "active": true + "active": false }, "ValidateFrameRange": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 184fc657be..93e6325b5a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -350,8 +350,8 @@ "name": "template_validate_plugin", "template_data": [ { - "key": "CollectFrameDataFromAssetEntity", - "label": "Collect frame range from asset entity" + "key": "CollectSequenceFrameData", + "label": "Collect Original Sequence Frame Data" }, { "key": "ValidateFrameRange", From 8897cdaa92f21e5c9de22cccd9e73ecc65c0c845 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 19:43:21 +0800 Subject: [PATCH 0252/1224] bug fix delete items from container to remove item --- openpype/hosts/max/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 3389447cb0..b23d156d0d 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -91,7 +91,7 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( current_selection = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined then return False + if current_selection == undefined or current_selection.count == 0 then return False temp_arr = #() i_node_arr = #() new_i_node_arr = #() From 1c8ab8ecfacc95543fa348a08c4e1b23495bc52c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Sep 2023 13:47:16 +0200 Subject: [PATCH 0253/1224] better label --- .../plugins/publish/collect_frame_data_from_asset_entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index 2950076cd0..e8a2cae16c 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -9,7 +9,7 @@ class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin): """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Frame Data From Asset" + label = "Collect Missing Frame Data From Asset" families = ["plate", "pointcache", "vdbcache", "online", "render"] From 723d187835a83d7f76c65017411b0a7263a8e18a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 19:54:01 +0800 Subject: [PATCH 0254/1224] hound --- openpype/hosts/max/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index b23d156d0d..31f01b6bbf 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -91,7 +91,7 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( current_selection = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined or current_selection.count == 0 then return False + if current_selection == undefined or current_selection.count == 0 then return False # noqa temp_arr = #() i_node_arr = #() new_i_node_arr = #() From edcaa8b62f0be86425da063efe55bc0377fa8c8c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:02:38 +0300 Subject: [PATCH 0255/1224] 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 0256/1224] 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 0257/1224] 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 42132e50e98208f581f97005dba839ba414265ee Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 21:34:57 +0800 Subject: [PATCH 0258/1224] # noqa makes the maxscript not working --- openpype/hosts/max/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 31f01b6bbf..b23d156d0d 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -91,7 +91,7 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( current_selection = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined or current_selection.count == 0 then return False # noqa + if current_selection == undefined or current_selection.count == 0 then return False temp_arr = #() i_node_arr = #() new_i_node_arr = #() From 05cef1ed91d1982edd6e0cf8143025f0968371d5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:38:52 +0300 Subject: [PATCH 0259/1224] 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 49e8a4b008a17ce97fb3ed4a77c53534f6e2509c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 21:42:08 +0800 Subject: [PATCH 0260/1224] hound --- openpype/hosts/max/api/plugin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index b23d156d0d..881295b317 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -91,7 +91,10 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( current_selection = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined or current_selection.count == 0 then return False + if current_selection == undefined or current_selection.count == 0 then + ( + return False + ) temp_arr = #() i_node_arr = #() new_i_node_arr = #() From 9fdd895bb6e4acb16313225f6c85604002471547 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 21:44:19 +0800 Subject: [PATCH 0261/1224] rename current_selection to current_sel --- openpype/hosts/max/api/plugin.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 881295b317..fa6db073db 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -65,12 +65,12 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_add pressed do ( - current_selection = selectByName title:"Select Objects to add to + current_sel = selectByName title:"Select Objects to add to the Container" buttontext:"Add" filter:nodes_to_add - if current_selection == undefined then return False + if current_sel == undefined then return False temp_arr = #() i_node_arr = #() - for c in current_selection do + for c in current_sel do ( handle_name = node_to_name c node_ref = NodeTransformMonitor node:c @@ -89,9 +89,9 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_del pressed do ( - current_selection = selectByName title:"Select Objects to remove + current_sel = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined or current_selection.count == 0 then + if current_sel == undefined or current_sel.count == 0 then ( return False ) @@ -100,7 +100,7 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" new_i_node_arr = #() new_temp_arr = #() - for c in current_selection do + for c in current_sel do ( node_ref = NodeTransformMonitor node:c as string handle_name = node_to_name c From 6deb9338cbd56447ea548f20eb0a51158b124b9e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 21:47:02 +0800 Subject: [PATCH 0262/1224] fix the typo of rstrip --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 9e41dbe8bc..351778d997 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3452,7 +3452,7 @@ def get_head_filename_without_hashes(original_path, name): "%.*d", "{{:0>{}}}".format(padding), fhead - ).rstip(".") + ).rstrip(".") new_fhead = "{}.{}".format(fhead, name) filename = filename.replace(fhead, new_fhead) return filename From 260650ea43b7850ead2a2820b02bca05eef8d710 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 16:51:50 +0300 Subject: [PATCH 0263/1224] 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 0264/1224] 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 0265/1224] 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 ebdcc49cd7895aa3e4aceadb0a0af116a0e41843 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 22:52:53 +0800 Subject: [PATCH 0266/1224] implement more concise function for getting filenames with hashes --- openpype/hosts/nuke/api/lib.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 351778d997..d34e7a1e0a 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3432,30 +3432,20 @@ def get_head_filename_without_hashes(original_path, name): Args: original_path (str): the filename with frame hashes - e.g. "renderCompositingMain.####.exr" + e.g. "renderCompositingMain.####.exr" name (str): the name of the tags - e.g. "baking" + e.g. "baking" Returns: - filename: the renamed filename with the tag - e.g. "renderCompositingMain.baking.####.exr" + str: the renamed filename with the tag + e.g. "renderCompositingMain.baking.####.exr" """ filename = os.path.basename(original_path) - fhead = os.path.splitext(filename)[0].strip(".") - if "#" in fhead: - fhead = re.sub("#+", "", fhead).rstrip(".") - elif "%" in fhead: - # use regex to convert %04d to {:0>4} - padding = re.search("%(\\d)+d", fhead) - padding = padding.group(1) if padding else 1 - fhead = re.sub( - "%.*d", - "{{:0>{}}}".format(padding), - fhead - ).rstrip(".") - new_fhead = "{}.{}".format(fhead, name) - filename = filename.replace(fhead, new_fhead) - return filename + + 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): From abef01cd0560ce60e108ed71c1de1d159b7efcb1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 22:54:18 +0800 Subject: [PATCH 0267/1224] edit docstring --- openpype/hosts/nuke/api/lib.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index d34e7a1e0a..cc2c5a6ec7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3430,15 +3430,20 @@ def get_head_filename_without_hashes(original_path, name): To avoid the system being confused on finding the filename with frame hashes if the head of the filename has the hashed symbol + Examples: + >>> get_head_filename_without_hashes("render.####.exr", "baking") + render.baking.####.exr + >>> get_head_filename_without_hashes("render.%d.exr", "tag") + render.tag.%d.exr + >>> get_head_filename_without_hashes("exr.####.exr", "foo") + exr.foo.%04d.exr + Args: original_path (str): the filename with frame hashes - e.g. "renderCompositingMain.####.exr" name (str): the name of the tags - e.g. "baking" Returns: str: the renamed filename with the tag - e.g. "renderCompositingMain.baking.####.exr" """ filename = os.path.basename(original_path) From e493886f4de4316778d3129d40f441f8ded24b71 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 23:17:12 +0800 Subject: [PATCH 0268/1224] improve docstring on lib.py and add comment on the condition of setting filename with extension and improved the deprecrated settings --- openpype/hosts/nuke/api/lib.py | 2 +- openpype/hosts/nuke/api/plugin.py | 4 ++++ .../publish/extract_review_baking_streams.py | 10 +++++----- openpype/settings/ayon_settings.py | 15 ++++++--------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index cc2c5a6ec7..dafc4bf838 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3463,7 +3463,7 @@ def get_filenames_without_hash(filename, frame_start, frame_end): frame_end (str): end of the frame Returns: - filenames(list): list of filename + list: filename per frame of the sequence """ filenames = [] for frame in range(int(frame_start), (int(frame_end) + 1)): diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 348a0b5d76..e16aef6740 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -818,9 +818,13 @@ 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 = get_head_filename_without_hashes( 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: original_ext = os.path.splitext(filename)[-1].strip(".") # noqa self.file = filename.replace(original_ext, ext) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py index d9ae673c2c..fe468bd263 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py @@ -33,13 +33,13 @@ class ExtractReviewDataBakingStreams(publish.Extractor): nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] current_setting = nuke_publish["ExtractReviewDataBakingStreams"] - if not deprecated_setting["enabled"]: - if current_setting["enabled"]: - cls.viewer_lut_raw = current_setting["viewer_lut_raw"] - cls.outputs = current_setting["outputs"] - else: + 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["enabled"]: + cls.viewer_lut_raw = current_setting["viewer_lut_raw"] + cls.outputs = current_setting["outputs"] def process(self, instance): families = set(instance.data["families"]) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index b43e0b7c5f..dc6e9fab12 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -754,11 +754,10 @@ def _convert_nuke_project_settings(ayon_settings, output): current_review_settings = ( ayon_publish["ExtractReviewDataBakingStreams"] ) - if not deprecrated_review_settings["enabled"]: - if current_review_settings["enabled"]: - outputs_settings = current_review_settings["outputs"] - else: + if deprecrated_review_settings["enabled"]: outputs_settings = deprecrated_review_settings["outputs"] + elif current_review_settings["enabled"]: + outputs_settings = current_review_settings["outputs"] for item in outputs_settings: item_filter = item["filter"] @@ -780,12 +779,10 @@ def _convert_nuke_project_settings(ayon_settings, output): name = item.pop("name") new_review_data_outputs[name] = item - if deprecrated_review_settings["outputs"] == ( - current_review_settings["outputs"] - ): - current_review_settings["outputs"] = new_review_data_outputs - else: + if deprecrated_review_settings["enabled"]: deprecrated_review_settings["outputs"] = new_review_data_outputs + elif current_review_settings["enabled"]: + current_review_settings["outputs"] = new_review_data_outputs collect_instance_data = ayon_publish["CollectInstanceData"] if "sync_workfile_version_on_product_types" in collect_instance_data: From 973e4804d5731dbb85e916177ecef7854d5d30d1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 27 Sep 2023 23:30:57 +0800 Subject: [PATCH 0269/1224] make sure not using .replace --- openpype/hosts/nuke/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index e16aef6740..81841d17be 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -826,8 +826,8 @@ class ExporterReviewMov(ExporterReview): # make sure the filename are in # correct image output format if ".{}".format(self.ext) not in self.file: - original_ext = os.path.splitext(filename)[-1].strip(".") # noqa - self.file = filename.replace(original_ext, ext) + filename_no_ext, _ = os.path.splitext(filename) + self.file = "{}.{}".format(filename_no_ext, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") From 61ce75f0c9e23f9d7f36d5bd2d7fc10fe5143514 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 27 Sep 2023 19:12:56 +0300 Subject: [PATCH 0270/1224] 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 0271/1224] 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 70d3f20de4c1963c67bd40be2613fc67ee34e017 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 28 Sep 2023 09:30:43 +0000 Subject: [PATCH 0272/1224] [Automated] Release --- CHANGELOG.md | 264 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 266 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf66a210..8f14340348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,270 @@ # Changelog +## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.0...3.17.1) + +### **🆕 New features** + + +
+Unreal: Yeti support #5643 + +Implemented Yeti support for Unreal. + + +___ + +
+ + +
+Houdini: Add Static Mesh product-type (family) #5481 + +This PR adds support to publish Unreal Static Mesh in Houdini as FBXQuick recap +- [x] Add UE Static Mesh Creator +- [x] Dynamic subset name like in Maya +- [x] Collect Static Mesh Type +- [x] Update collect output node +- [x] Validate FBX output node +- [x] Validate mesh is static +- [x] Validate Unreal Static Mesh Name +- [x] Validate Subset Name +- [x] FBX Extractor +- [x] FBX Loader +- [x] Update OP Settings +- [x] Update AYON Settings + + +___ + +
+ + +
+Launcher tool: Refactor launcher tool (for AYON) #5612 + +Refactored launcher 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. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Use custom staging dir function for Maya renders - OP-5265 #5186 + +Check for custom staging dir when setting the renders output folder in Maya. + + +___ + +
+ + +
+Colorspace: updating file path detection methods #5273 + +Support for OCIO v2 file rules integrated into the available color management API + + +___ + +
+ + +
+Chore: add default isort config #5572 + +Add default configuration for isort tool + + +___ + +
+ + +
+Deadline: set PATH environment in deadline jobs by GlobalJobPreLoad #5622 + +This PR makes `GlobalJobPreLoad` to set `PATH` environment in deadline jobs so that we don't have to use the full executable path for deadline to launch the dcc app. This trick should save us adding logic to pass houdini patch version and modifying Houdini deadline plugin. This trick should work with other DCCs + + +___ + +
+ + +
+nuke: extract review data mov read node with expression #5635 + +Some productions might have set default values for read nodes, those settings are not colliding anymore now. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Support new publisher for colorsets validation. #5630 + +Fix `validate_color_sets` for the new publisher.In current `develop` the repair option does not appear due to wrong error raising. + + +___ + +
+ + +
+Houdini: Camera Loader fix mismatch for Maya cameras #5584 + +This PR adds +- A workaround to match Maya render mask in Houdini +- `SetCameraResolution` inventory action +- set camera resolution when loading or updating camera + + +___ + +
+ + +
+Nuke: fix set colorspace on writes #5634 + +Colorspace is set correctly to any write node created from publisher. + + +___ + +
+ + +
+TVPaint: Fix review family extraction #5637 + +Extractor marks representation of review instance with review tag. + + +___ + +
+ + +
+AYON settings: Extract OIIO transcode settings #5639 + +Output definitions of Extract OIIO transcode have name to match OpenPype settings, and the settings are converted to dictionary in settings conversion. + + +___ + +
+ + +
+AYON: Fix task type short name conversion #5641 + +Convert AYON task type short name for OpenPype correctly. + + +___ + +
+ + +
+colorspace: missing `allowed_exts` fix #5646 + +Colorspace module is not failing due to missing `allowed_exts` attribute. + + +___ + +
+ + +
+Photoshop: remove trailing underscore in subset name #5647 + +If {layer} placeholder is at the end of subset name template and not used (for example in `auto_image` where separating it by layer doesn't make any sense) trailing '_' was kept. This updates cleaning logic and extracts it as it might be similar in regular `image` instance. + + +___ + +
+ + +
+traypublisher: missing `assetEntity` in context data #5648 + +Issue with missing `assetEnity` key in context data is not problem anymore. + + +___ + +
+ + +
+AYON: Workfiles tool save button works #5653 + +Fix save as button in workfiles tool.(It is mystery why this stopped to work??) + + +___ + +
+ + +
+Max: bug fix delete items from container #5658 + +Fix the bug shown when clicking "Delete Items from Container" and selecting nothing and press ok. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Chore: Remove unused functions from Fusion integration #5617 + +Cleanup unused code from Fusion integration + + +___ + +
+ +### **Merged pull requests** + + +
+Increase timout for deadline test #5654 + +DL picks up jobs quite slow, so bump up delay. + + +___ + +
+ + + + ## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0) diff --git a/openpype/version.py b/openpype/version.py index c8ae6dffd8..f1e0cd0b80 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.1-nightly.3" +__version__ = "3.17.1" diff --git a/pyproject.toml b/pyproject.toml index d0b1ecf589..2460185bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.0" # OpenPype +version = "3.17.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 030d5843fa2baba9e723067f34db0e1dbc46f297 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 Sep 2023 09:31:44 +0000 Subject: [PATCH 0273/1224] 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 a2edd28f5b..591d865ca5 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.1 - 3.17.1-nightly.3 - 3.17.1-nightly.2 - 3.17.1-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.7 - 3.14.10-nightly.6 - 3.14.10-nightly.5 - - 3.14.10-nightly.4 validations: required: true - type: dropdown From ed02bf31114e885a157c8ef13f8474cc86d093a1 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 17:51:08 +0800 Subject: [PATCH 0274/1224] 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 356f05ff91efd32dcecbb72081f94740ec8df908 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 28 Sep 2023 19:20:37 +0800 Subject: [PATCH 0275/1224] docstring tweaks --- openpype/hosts/nuke/api/lib.py | 2 +- .../nuke/plugins/publish/extract_review_baking_streams.py | 6 +++--- openpype/settings/ayon_settings.py | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index dafc4bf838..07f394ec00 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3433,7 +3433,7 @@ def get_head_filename_without_hashes(original_path, name): Examples: >>> get_head_filename_without_hashes("render.####.exr", "baking") render.baking.####.exr - >>> get_head_filename_without_hashes("render.%d.exr", "tag") + >>> get_head_filename_without_hashes("render.%04d.exr", "tag") render.tag.%d.exr >>> get_head_filename_without_hashes("exr.####.exr", "foo") exr.foo.%04d.exr diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py index fe468bd263..1ba107a3e7 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py @@ -9,7 +9,7 @@ from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataBakingStreams(publish.Extractor): - """Extracts movie and thumbnail with baked in luts + """Extracts Sequences and thumbnail with baked in luts must be run after extract_render_local.py @@ -27,8 +27,8 @@ class ExtractReviewDataBakingStreams(publish.Extractor): @classmethod def apply_settings(cls, project_settings): - """just in case there are some old presets - in deprecated ExtractReviewDataMov Plugins + """Apply the settings from the deprecated + ExtractReviewDataMov plugin for backwards compatibility """ nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index dc6e9fab12..f23046e6c4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -749,7 +749,8 @@ def _convert_nuke_project_settings(ayon_settings, output): new_review_data_outputs = {} outputs_settings = None - # just in case that the users having old presets in outputs setting + # Check deprecated ExtractReviewDataMov + # settings for backwards compatibility deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] current_review_settings = ( ayon_publish["ExtractReviewDataBakingStreams"] From a433c46e727862a28a0a4835f582135b588e67e6 Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 19:50:44 +0800 Subject: [PATCH 0276/1224] 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 6c1e066b3b7d58eba28d38900a886a0c89c556b5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 28 Sep 2023 20:07:28 +0800 Subject: [PATCH 0277/1224] Rename ExtractReviewDataBakingStreams to ExtractReviewIntermediate --- .../nuke/plugins/publish/extract_review_baking_streams.py | 6 +++--- openpype/settings/ayon_settings.py | 2 +- openpype/settings/defaults/project_settings/nuke.json | 2 +- .../projects_schema/schemas/schema_nuke_publish.json | 6 +++--- server_addon/nuke/server/settings/publish_plugins.py | 4 ++-- website/docs/project_settings/settings_project_global.md | 2 +- website/docs/pype2/admin_presets_plugins.md | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py index 1ba107a3e7..4407c039b4 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py @@ -8,7 +8,7 @@ from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataBakingStreams(publish.Extractor): +class ExtractReviewIntermediate(publish.Extractor): """Extracts Sequences and thumbnail with baked in luts must be run after extract_render_local.py @@ -16,7 +16,7 @@ class ExtractReviewDataBakingStreams(publish.Extractor): """ order = pyblish.api.ExtractorOrder + 0.01 - label = "Extract Review Data Baking Streams" + label = "Extract Review Intermediate" families = ["review"] hosts = ["nuke"] @@ -32,7 +32,7 @@ class ExtractReviewDataBakingStreams(publish.Extractor): """ nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] - current_setting = nuke_publish["ExtractReviewDataBakingStreams"] + current_setting = nuke_publish["ExtractReviewIntermediate"] if deprecated_setting["enabled"]: # Use deprecated settings if they are still enabled cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index f23046e6c4..fbf35aec0a 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -753,7 +753,7 @@ def _convert_nuke_project_settings(ayon_settings, output): # settings for backwards compatibility deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] current_review_settings = ( - ayon_publish["ExtractReviewDataBakingStreams"] + ayon_publish["ExtractReviewIntermediate"] ) if deprecrated_review_settings["enabled"]: outputs_settings = deprecrated_review_settings["outputs"] diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index fac78dbcd5..7346c9d7b8 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -501,7 +501,7 @@ } } }, - "ExtractReviewDataBakingStreams": { + "ExtractReviewIntermediate": { "enabled": true, "viewer_lut_raw": false, "outputs": { 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 0f366d55ba..c14f47a3a7 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 @@ -373,14 +373,14 @@ }, { "type": "label", - "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewDataBakingStreams instead." + "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewIntermediate instead." }, { "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ExtractReviewDataBakingStreams", - "label": "ExtractReviewDataBakingStreams", + "key": "ExtractReviewIntermediate", + "label": "ExtractReviewIntermediate", "is_group": true, "children": [ { diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 6459dd7225..399aa7e38e 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -282,7 +282,7 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Mov", default_factory=ExtractReviewDataMovModel ) - ExtractReviewDataBakingStreams: ExtractReviewBakingStreamsModel = Field( + ExtractReviewIntermediate: ExtractReviewBakingStreamsModel = Field( title="Extract Review Data Baking Streams", default_factory=ExtractReviewBakingStreamsModel ) @@ -481,7 +481,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { } ] }, - "ExtractReviewDataBakingStreams": { + "ExtractReviewIntermediate": { "enabled": True, "viewer_lut_raw": False, "outputs": [ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 8ecfe0c5da..3aa9772118 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -189,7 +189,7 @@ A profile may generate multiple outputs from a single input. Each output must de - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). - - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataBakingStreams/outputs/baking/add_custom_tags` + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewIntermediate/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index a869ead819..a039c5fbd8 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -534,7 +534,7 @@ Plugin responsible for generating thumbnails with colorspace controlled by Nuke. } ``` -### `ExtractReviewDataBakingStreams` +### `ExtractReviewIntermediate` `viewer_lut_raw` **true** will publish the baked mov file without any colorspace conversion. It will be baked with the workfile workspace. This can happen in case the Viewer input process uses baked screen space luts. #### baking with controlled colorspace From ef12a5229dec982aa525f9fee2c29493263e0faf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 28 Sep 2023 20:27:55 +0800 Subject: [PATCH 0278/1224] plural form for extract_review_intermediate --- ...king_streams.py => extract_review_intermediates.py} | 4 ++-- openpype/settings/ayon_settings.py | 2 +- openpype/settings/defaults/project_settings/nuke.json | 2 +- .../projects_schema/schemas/schema_nuke_publish.json | 6 +++--- server_addon/nuke/server/settings/publish_plugins.py | 10 +++++----- .../docs/project_settings/settings_project_global.md | 2 +- website/docs/pype2/admin_presets_plugins.md | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) rename openpype/hosts/nuke/plugins/publish/{extract_review_baking_streams.py => extract_review_intermediates.py} (99%) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py similarity index 99% rename from openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py rename to openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 4407c039b4..2d996b1381 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_baking_streams.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -8,7 +8,7 @@ from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewIntermediate(publish.Extractor): +class ExtractReviewIntermediates(publish.Extractor): """Extracts Sequences and thumbnail with baked in luts must be run after extract_render_local.py @@ -32,7 +32,7 @@ class ExtractReviewIntermediate(publish.Extractor): """ nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] - current_setting = nuke_publish["ExtractReviewIntermediate"] + current_setting = nuke_publish["ExtractReviewIntermediates"] if deprecated_setting["enabled"]: # Use deprecated settings if they are still enabled cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index fbf35aec0a..68693bb953 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -753,7 +753,7 @@ def _convert_nuke_project_settings(ayon_settings, output): # settings for backwards compatibility deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] current_review_settings = ( - ayon_publish["ExtractReviewIntermediate"] + ayon_publish["ExtractReviewIntermediates"] ) if deprecrated_review_settings["enabled"]: outputs_settings = deprecrated_review_settings["outputs"] diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 7346c9d7b8..ad9f46c8ab 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -501,7 +501,7 @@ } } }, - "ExtractReviewIntermediate": { + "ExtractReviewIntermediates": { "enabled": true, "viewer_lut_raw": false, "outputs": { 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 c14f47a3a7..fa08e19c63 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 @@ -373,14 +373,14 @@ }, { "type": "label", - "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewIntermediate instead." + "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewIntermediates instead." }, { "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ExtractReviewIntermediate", - "label": "ExtractReviewIntermediate", + "key": "ExtractReviewIntermediates", + "label": "ExtractReviewIntermediates", "is_group": true, "children": [ { diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 399aa7e38e..efb814eff0 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -177,7 +177,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel): ) -class ExtractReviewBakingStreamsModel(BaseSettingsModel): +class ExtractReviewIntermediatesModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") outputs: list[BakingStreamModel] = Field( @@ -282,9 +282,9 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Mov", default_factory=ExtractReviewDataMovModel ) - ExtractReviewIntermediate: ExtractReviewBakingStreamsModel = Field( - title="Extract Review Data Baking Streams", - default_factory=ExtractReviewBakingStreamsModel + ExtractReviewIntermediates: ExtractReviewIntermediatesModel = Field( + title="Extract Review Intermediates", + default_factory=ExtractReviewIntermediatesModel ) ExtractSlateFrame: ExtractSlateFrameModel = Field( title="Extract Slate Frame", @@ -481,7 +481,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { } ] }, - "ExtractReviewIntermediate": { + "ExtractReviewIntermediates": { "enabled": True, "viewer_lut_raw": False, "outputs": [ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 3aa9772118..27aa60a464 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -189,7 +189,7 @@ A profile may generate multiple outputs from a single input. Each output must de - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). - - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewIntermediate/outputs/baking/add_custom_tags` + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewIntermediates/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index a039c5fbd8..b5e8a3b8a8 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -534,7 +534,7 @@ Plugin responsible for generating thumbnails with colorspace controlled by Nuke. } ``` -### `ExtractReviewIntermediate` +### `ExtractReviewIntermediates` `viewer_lut_raw` **true** will publish the baked mov file without any colorspace conversion. It will be baked with the workfile workspace. This can happen in case the Viewer input process uses baked screen space luts. #### baking with controlled colorspace From f45552ff79fcc72b38825886584b4cecf9160a43 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 28 Sep 2023 20:29:09 +0800 Subject: [PATCH 0279/1224] label tweak --- .../hosts/nuke/plugins/publish/extract_review_intermediates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 2d996b1381..78fb37e8d7 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -16,7 +16,7 @@ class ExtractReviewIntermediates(publish.Extractor): """ order = pyblish.api.ExtractorOrder + 0.01 - label = "Extract Review Intermediate" + label = "Extract Review Intermediates" families = ["review"] hosts = ["nuke"] From bf16f8492f39cce8c6b5c7cf39714a459180f94e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 28 Sep 2023 15:05:13 +0100 Subject: [PATCH 0280/1224] Improved update for Static Meshes --- openpype/hosts/unreal/api/pipeline.py | 72 +++++++++ .../plugins/load/load_staticmesh_abc.py | 131 ++++++++++------- .../plugins/load/load_staticmesh_fbx.py | 137 +++++++++++------- 3 files changed, 237 insertions(+), 103 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 72816c9b81..ae38601df2 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -649,6 +649,78 @@ def generate_sequence(h, h_dir): return sequence, (min_frame, max_frame) +def replace_static_mesh_actors(old_assets, new_assets): + eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) + + comps = eas.get_all_level_actors_components() + static_mesh_comps = [ + c for c in comps if isinstance(c, unreal.StaticMeshComponent) + ] + + # Get all the static meshes among the old assets in a dictionary with + # the name as key + old_meshes = {} + for a in old_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, unreal.StaticMesh): + old_meshes[asset.get_name()] = asset + + # Get all the static meshes among the new assets in a dictionary with + # the name as key + new_meshes = {} + for a in new_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, unreal.StaticMesh): + new_meshes[asset.get_name()] = asset + + for old_name, old_mesh in old_meshes.items(): + new_mesh = new_meshes.get(old_name) + + if not new_mesh: + continue + + smes.replace_mesh_components_meshes( + static_mesh_comps, old_mesh, new_mesh) + +def delete_previous_asset_if_unused(container, asset_content): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + references = set() + + for asset_path in asset_content: + asset = ar.get_asset_by_object_path(asset_path) + refs = ar.get_referencers( + asset.package_name, + unreal.AssetRegistryDependencyOptions( + include_soft_package_references=False, + include_hard_package_references=True, + include_searchable_names=False, + include_soft_management_references=False, + include_hard_management_references=False + )) + if not refs: + continue + references = references.union(set(refs)) + + # Filter out references that are in the Temp folder + cleaned_references = { + ref for ref in references if not str(ref).startswith("/Temp/")} + + # Check which of the references are Levels + for ref in cleaned_references: + loaded_asset = unreal.EditorAssetLibrary.load_asset(ref) + if isinstance(loaded_asset, unreal.World): + # If there is at least a level, we can stop, we don't want to + # delete the container + return + + unreal.log("Previous version unused, deleting...") + + # No levels, delete the asset + unreal.EditorAssetLibrary.delete_directory(container["namespace"]) + + @contextmanager def maintained_selection(): """Stub to be either implemented or replaced. diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index bb13692f9e..13c4ec23e9 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -7,7 +7,12 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + create_container, + imprint, + replace_static_mesh_actors, + delete_previous_asset_if_unused, +) import unreal # noqa @@ -20,6 +25,8 @@ class StaticMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" + root = "/Game/Ayon/Assets" + @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() @@ -53,14 +60,40 @@ class StaticMeshAlembicLoader(plugin.Loader): return task + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + default_conversion=False + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = self.get_task( + filepath, asset_dir, asset_name, False, default_conversion) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + # Create Asset Container + create_container(container=container_name, path=asset_dir) + + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] + } + imprint(f"{asset_dir}/{container_name}", data) + def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - Args: context (dict): application context name (str): subset name @@ -68,15 +101,13 @@ class StaticMeshAlembicLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + data (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" @@ -93,39 +124,22 @@ class StaticMeshAlembicLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name_version}", suffix="") + f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) - path = self.filepath_from_context(context) - task = self.get_task( - path, asset_dir, asset_name, False, default_conversion) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + self.import_and_containerize(path, asset_dir, asset_name, + container_name, default_conversion) - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) - - data = { - "schema": "ayon:container-2.0", - "id": AYON_CONTAINER_ID, - "asset": asset, - "namespace": asset_dir, - "container_name": container_name, - "asset_name": asset_name, - "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] - } - unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( - asset_dir, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: @@ -134,32 +148,51 @@ class StaticMeshAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True, False) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) + old_assets = unreal.EditorAssetLibrary.list_assets( + container["namespace"], recursive=True, include_folder=False + ) + + replace_static_mesh_actors(old_assets, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(container, old_assets) + def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index ffc68d8375..947e0cc041 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -7,7 +7,12 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + create_container, + imprint, + replace_static_mesh_actors, + delete_previous_asset_if_unused, +) import unreal # noqa @@ -20,6 +25,8 @@ class StaticMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" + root = "/Game/Ayon/Assets" + @staticmethod def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() @@ -46,14 +53,39 @@ class StaticMeshFBXLoader(plugin.Loader): return task + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = self.get_task( + filepath, asset_dir, asset_name, False) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + # Create Asset Container + create_container(container=container_name, path=asset_dir) + + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] + } + imprint(f"{asset_dir}/{container_name}", data) + def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - Args: context (dict): application context name (str): subset name @@ -61,23 +93,16 @@ class StaticMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not - used now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" - if options and options.get("asset_dir"): - root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) + asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": @@ -87,35 +112,20 @@ class StaticMeshFBXLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name_version}", suffix="" + f"{self.root}/{asset}/{name_version}", suffix="" ) container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) - path = self.filepath_from_context(context) - task = self.get_task(path, asset_dir, asset_name, False) + self.import_and_containerize( + path, asset_dir, asset_name, container_name) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) - - data = { - "schema": "ayon:container-2.0", - "id": AYON_CONTAINER_ID, - "asset": asset, - "namespace": asset_dir, - "container_name": container_name, - "asset_name": asset_name, - "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] - } - unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -127,32 +137,51 @@ class StaticMeshFBXLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) + old_assets = unreal.EditorAssetLibrary.list_assets( + container["namespace"], recursive=True, include_folder=False + ) + + replace_static_mesh_actors(old_assets, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(container, old_assets) + def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) From dda932e83ef448c74533ea39f687a6d919a2551c Mon Sep 17 00:00:00 2001 From: Kayla Date: Thu, 28 Sep 2023 22:37:30 +0800 Subject: [PATCH 0281/1224] 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 0282/1224] 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 0283/1224] 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 0284/1224] 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 0285/1224] 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 0286/1224] 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 0287/1224] 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 0288/1224] 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 f57c1eb8889fea3b29a85ef0e425c909236dcc7a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 28 Sep 2023 23:32:39 +0800 Subject: [PATCH 0289/1224] edit docsting and rename BakingStreamModel as IntermediateOutputModel --- .../nuke/plugins/publish/extract_review_intermediates.py | 3 ++- server_addon/nuke/server/settings/publish_plugins.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 78fb37e8d7..da060e3157 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -9,7 +9,8 @@ from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewIntermediates(publish.Extractor): - """Extracts Sequences and thumbnail with baked in luts + """Extracting intermediate videos or sequences with + thumbnail for transcoding. must be run after extract_render_local.py diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index efb814eff0..19206149b6 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -149,7 +149,7 @@ class ReformatNodesConfigModel(BaseSettingsModel): ) -class BakingStreamModel(BaseSettingsModel): +class IntermediateOutputModel(BaseSettingsModel): name: str = Field(title="Output name") filter: BakingStreamFilterModel = Field( title="Filter", default_factory=BakingStreamFilterModel) @@ -171,7 +171,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel): """ enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") - outputs: list[BakingStreamModel] = Field( + outputs: list[IntermediateOutputModel] = Field( default_factory=list, title="Baking streams" ) @@ -180,7 +180,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel): class ExtractReviewIntermediatesModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") - outputs: list[BakingStreamModel] = Field( + outputs: list[IntermediateOutputModel] = Field( default_factory=list, title="Baking streams" ) From ea4ce1b8be7a721124445770a6169ab960145b3a Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 15:17:41 +0800 Subject: [PATCH 0290/1224] 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 ebaaa448086a7fefc7214813884d356b942947cc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 29 Sep 2023 15:44:20 +0800 Subject: [PATCH 0291/1224] make sure tycache output filenames in representation data are correct --- .../max/plugins/publish/extract_tycache.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index c3e9489d43..409ade8f76 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -37,7 +37,8 @@ class ExtractTyCache(publish.Extractor): stagingdir = self.staging_dir(instance) filename = "{name}.tyc".format(**instance.data) path = os.path.join(stagingdir, filename) - filenames = self.get_file(path, start, end) + filenames = self.get_file(instance, start, end) + self.log.debug(f"filenames: {filenames}") additional_attributes = instance.data.get("tyc_attrs", {}) with maintained_selection(): @@ -66,19 +67,20 @@ class ExtractTyCache(publish.Extractor): instance.data["representations"].append(representation) self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") - def get_file(self, filepath, start_frame, end_frame): + def get_file(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. Set the filenames accordingly to the tyCache file naming extension(.tyc) for the publishing purpose Actual File Output from tyFlow in tyCache format: - _.tyc + __tyPart_.tyc + __tyMesh.tyc - e.g. tyFlow_cloth_CCCS_blobbyFill_001_00004.tyc + e.g. tycacheMain__tyPart_00000.tyc Args: - fileapth (str): Output directory. + instance (str): instance. start_frame (int): Start frame. end_frame (int): End frame. @@ -87,13 +89,11 @@ class ExtractTyCache(publish.Extractor): """ filenames = [] - filename = os.path.basename(filepath) - orig_name, _ = os.path.splitext(filename) + # should we include frame 0 ? for frame in range(int(start_frame), int(end_frame) + 1): - actual_name = "{}_{:05}".format(orig_name, frame) - actual_filename = filepath.replace(orig_name, actual_name) - filenames.append(os.path.basename(actual_filename)) - + filename = "{}__tyPart_{:05}.tyc".format(instance.name, frame) + filenames.append(filename) + filenames.append("{}__tyMesh.tyc".format(instance.name)) return filenames def export_particle(self, members, start, end, From be353bd4395dce0450dbdd4ad6811089a1cc8b34 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 29 Sep 2023 15:47:04 +0800 Subject: [PATCH 0292/1224] remove unnecessary debug check --- openpype/hosts/max/plugins/publish/extract_tycache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 409ade8f76..5fa8642809 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -38,7 +38,6 @@ class ExtractTyCache(publish.Extractor): filename = "{name}.tyc".format(**instance.data) path = os.path.join(stagingdir, filename) filenames = self.get_file(instance, start, end) - self.log.debug(f"filenames: {filenames}") additional_attributes = instance.data.get("tyc_attrs", {}) with maintained_selection(): From 671844fa077ac9ef7d379f4b4bf46b3bee972537 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 29 Sep 2023 11:02:25 +0100 Subject: [PATCH 0293/1224] Improved update for Skeletal Meshes --- openpype/hosts/unreal/api/pipeline.py | 36 +++ .../plugins/load/load_skeletalmesh_abc.py | 198 +++++++++------ .../plugins/load/load_skeletalmesh_fbx.py | 233 +++++++++--------- .../plugins/load/load_staticmesh_abc.py | 1 - .../plugins/load/load_staticmesh_fbx.py | 1 - 5 files changed, 274 insertions(+), 195 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index ae38601df2..39638ac40f 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -683,6 +683,42 @@ def replace_static_mesh_actors(old_assets, new_assets): smes.replace_mesh_components_meshes( static_mesh_comps, old_mesh, new_mesh) + +def replace_skeletal_mesh_actors(old_assets, new_assets): + eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + + comps = eas.get_all_level_actors_components() + skeletal_mesh_comps = [ + c for c in comps if isinstance(c, unreal.SkeletalMeshComponent) + ] + + # Get all the static meshes among the old assets in a dictionary with + # the name as key + old_meshes = {} + for a in old_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, unreal.SkeletalMesh): + old_meshes[asset.get_name()] = asset + + # Get all the static meshes among the new assets in a dictionary with + # the name as key + new_meshes = {} + for a in new_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, unreal.SkeletalMesh): + new_meshes[asset.get_name()] = asset + + for old_name, old_mesh in old_meshes.items(): + new_mesh = new_meshes.get(old_name) + + if not new_mesh: + continue + + for comp in skeletal_mesh_comps: + if comp.get_skeletal_mesh_asset() == old_mesh: + comp.set_skeletal_mesh_asset(new_mesh) + + def delete_previous_asset_if_unused(container, asset_content): ar = unreal.AssetRegistryHelpers.get_asset_registry() diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 9285602b64..2e6557bd2d 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -7,7 +7,13 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + create_container, + imprint, + replace_static_mesh_actors, + replace_skeletal_mesh_actors, + delete_previous_asset_if_unused, +) import unreal # noqa @@ -20,10 +26,12 @@ class SkeletalMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" - def get_task(self, filename, asset_dir, asset_name, replace): + root = "/Game/Ayon/Assets" + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() - sm_settings = unreal.AbcStaticMeshSettings() conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, @@ -37,72 +45,38 @@ class SkeletalMeshAlembicLoader(plugin.Loader): task.set_editor_property('automated', True) task.set_editor_property('save', True) - # set import options here - # Unreal 4.24 ignores the settings. It works with Unreal 4.26 options.set_editor_property( 'import_type', unreal.AlembicImportType.SKELETAL) - options.static_mesh_settings = sm_settings - options.conversion_settings = conversion_settings + if not default_conversion: + conversion_settings = unreal.AbcConversionSettings( + preset=unreal.AbcConversionPreset.CUSTOM, + flip_u=False, flip_v=False, + rotation=[0.0, 0.0, 0.0], + scale=[1.0, 1.0, 1.0]) + options.conversion_settings = conversion_settings + task.options = options return task - def load(self, context, name, namespace, data): - """Load and containerise representation into Content Browser. + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + default_conversion=False + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + task = self.get_task( + filepath, asset_dir, asset_name, False, default_conversion) - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - Returns: - list(str): list of container content - """ - - # Create directory for asset and ayon container - root = "/Game/Ayon/Assets" - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - version = context.get('version') - # Check if version is hero version and use different name - if not version.get("name") and version.get('type') == "hero_version": - name_version = f"{name}_hero" - else: - name_version = f"{name}_v{version.get('name'):03d}" - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name_version}", suffix="") - - container_name += suffix - - if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) - - path = self.filepath_from_context(context) - task = self.get_task(path, asset_dir, asset_name, False) - - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + create_container(container=container_name, path=asset_dir) + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, @@ -111,12 +85,57 @@ class SkeletalMeshAlembicLoader(plugin.Loader): "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] } - unreal_pipeline.imprint( - f"{asset_dir}/{container_name}", data) + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. + + Returns: + list(str): list of container content + """ + # Create directory for asset and ayon container + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + default_conversion = False + if options.get("default_conversion"): + default_conversion = options.get("default_conversion") + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name, default_conversion) + + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -128,31 +147,52 @@ class SkeletalMeshAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) + old_assets = unreal.EditorAssetLibrary.list_assets( + container["namespace"], recursive=True, include_folder=False + ) + + replace_static_mesh_actors(old_assets, asset_content) + replace_skeletal_mesh_actors(old_assets, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(container, old_assets) + def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 9aa0e4d1a8..3c84f36399 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -7,7 +7,13 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + create_container, + imprint, + replace_static_mesh_actors, + replace_skeletal_mesh_actors, + delete_previous_asset_if_unused, +) import unreal # noqa @@ -20,14 +26,79 @@ class SkeletalMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" + root = "/Game/Ayon/Assets" + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): + task = unreal.AssetImportTask() + options = unreal.FbxImportUI() + + task.set_editor_property('filename', filename) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', replace) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + options.set_editor_property( + 'automated_import_should_detect_type', False) + options.set_editor_property('import_as_skeletal', True) + options.set_editor_property('import_animations', False) + options.set_editor_property('import_mesh', True) + options.set_editor_property('import_materials', False) + options.set_editor_property('import_textures', False) + options.set_editor_property('skeleton', None) + options.set_editor_property('create_physics_asset', False) + + options.set_editor_property( + 'mesh_type_to_import', + unreal.FBXImportType.FBXIT_SKELETAL_MESH) + + options.skeletal_mesh_import_data.set_editor_property( + 'import_content_type', + unreal.FBXImportContentType.FBXICT_ALL) + + options.skeletal_mesh_import_data.set_editor_property( + 'normal_import_method', + unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) + + task.options = options + + return task + + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = self.get_task( + filepath, asset_dir, asset_name, False) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + # Create Asset Container + create_container(container=container_name, path=asset_dir) + + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] + } + imprint(f"{asset_dir}/{container_name}", data) + def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. - This is a two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - Args: context (dict): application context name (str): subset name @@ -35,23 +106,15 @@ class SkeletalMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not - used now, data are imprinted by `containerise()`. + data (dict): Those would be data to be imprinted. Returns: list(str): list of container content - """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" - if options and options.get("asset_dir"): - root = options["asset_dir"] asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) + asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": @@ -61,67 +124,20 @@ class SkeletalMeshFBXLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name_version}", suffix="") + f"{self.root}/{asset}/{name_version}", suffix="" + ) container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) - - task = unreal.AssetImportTask() - path = self.filepath_from_context(context) - task.set_editor_property('filename', path) - task.set_editor_property('destination_path', asset_dir) - task.set_editor_property('destination_name', asset_name) - task.set_editor_property('replace_existing', False) - task.set_editor_property('automated', True) - task.set_editor_property('save', False) - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', False) - options.set_editor_property('import_textures', False) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + self.import_and_containerize( + path, asset_dir, asset_name, container_name) - options.set_editor_property( - 'mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) - - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) - - task.options = options - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) - - data = { - "schema": "ayon:container-2.0", - "id": AYON_CONTAINER_ID, - "asset": asset, - "namespace": asset_dir, - "container_name": container_name, - "asset_name": asset_name, - "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] - } - unreal_pipeline.imprint( - f"{asset_dir}/{container_name}", data) + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -133,63 +149,52 @@ class SkeletalMeshFBXLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = unreal.AssetImportTask() + if not context: + raise RuntimeError("No context found in representation") - task.set_editor_property('filename', source_path) - task.set_editor_property('destination_path', destination_path) - task.set_editor_property('destination_name', name) - task.set_editor_property('replace_existing', True) - task.set_editor_property('automated', True) - task.set_editor_property('save', True) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', True) - options.set_editor_property('import_textures', True) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + container_name += suffix - options.set_editor_property('mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL - ) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS - ) + self.import_and_containerize( + path, asset_dir, asset_name, container_name) - task.options = options - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) + old_assets = unreal.EditorAssetLibrary.list_assets( + container["namespace"], recursive=True, include_folder=False + ) + + replace_static_mesh_actors(old_assets, asset_content) + replace_skeletal_mesh_actors(old_assets, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(container, old_assets) + def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index 13c4ec23e9..cc7aed7b93 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -105,7 +105,6 @@ class StaticMeshAlembicLoader(plugin.Loader): Returns: list(str): list of container content - """ # Create directory for asset and Ayon container asset = context.get('asset').get('name') diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index 947e0cc041..0aac69b57b 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -98,7 +98,6 @@ class StaticMeshFBXLoader(plugin.Loader): Returns: list(str): list of container content """ - # Create directory for asset and Ayon container asset = context.get('asset').get('name') suffix = "_CON" From 37cefd892c85218dc3614341866b5332c7384e10 Mon Sep 17 00:00:00 2001 From: Kayla Date: Fri, 29 Sep 2023 18:26:58 +0800 Subject: [PATCH 0294/1224] 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 0295/1224] 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 0296/1224] 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 0297/1224] 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 0298/1224] 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 0299/1224] 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 0300/1224] 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 0301/1224] 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 0302/1224] 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 0303/1224] 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 759dc59132bc32ce07b6f204c8da7df8ae9f2715 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 29 Sep 2023 17:33:27 +0200 Subject: [PATCH 0304/1224] add clip to timeline in correct place --- openpype/hosts/resolve/api/lib.py | 9 ++++++++- openpype/hosts/resolve/api/plugin.py | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index eaee3bb9ba..4e8b3a4107 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -246,7 +246,8 @@ def get_media_pool_item(fpath, root: object = None) -> object: def create_timeline_item(media_pool_item: object, timeline: object = None, source_start: int = None, - source_end: int = None) -> object: + source_end: int = None, + timeline_in: int = None) -> object: """ Add media pool item to current or defined timeline. @@ -278,6 +279,12 @@ def create_timeline_item(media_pool_item: object, clip_data.update({"endFrame": source_end}) print(clip_data) + + if timeline_in: + timeline_start = timeline.GetStartFrame() + # Create a clipInfo dictionary with the necessary information + clip_data["recordFrame"] = int(timeline_start + timeline_in) + # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index e2bd76ffa2..c679aa062d 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -402,6 +402,9 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) + self.timeline_in = int(self.data["assetData"]["clipIn"]) + + source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -416,7 +419,12 @@ class ClipLoader: # make track item from source in bin as item timeline_item = lib.create_timeline_item( - media_pool_item, self.active_timeline, source_in, source_out) + media_pool_item, + self.active_timeline, + source_in, + source_out, + self.timeline_in + ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item From a66edaf1d051be4c6be2cfe189fe7a5912296968 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Sep 2023 17:33:28 +0200 Subject: [PATCH 0305/1224] 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 0306/1224] 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 0307/1224] 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 0308/1224] 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 0309/1224] 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 0310/1224] [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 0311/1224] 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 0312/1224] 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 0313/1224] 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 0314/1224] 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 0315/1224] 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 0316/1224] 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 0317/1224] 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 0318/1224] 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 0319/1224] 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 0320/1224] 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 0321/1224] 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 0322/1224] 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 7cd8be0afa68005bc615523fd4a0ee55f73e1a30 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 11:49:51 +0200 Subject: [PATCH 0323/1224] resolve: add option for adding clips sequentially - or to asset define place - also create track with a name --- openpype/hosts/resolve/api/lib.py | 12 +++--- openpype/hosts/resolve/api/plugin.py | 55 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 4e8b3a4107..8f7eba8a90 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -274,17 +274,15 @@ def create_timeline_item(media_pool_item: object, # add source time range if input was given if source_start is not None: - clip_data.update({"startFrame": source_start}) + clip_data["startFrame"] = source_start if source_end is not None: - clip_data.update({"endFrame": source_end}) + clip_data["endFrame"] = source_end + + # Create a clipInfo dictionary with the necessary information + clip_data["recordFrame"] = timeline_in print(clip_data) - if timeline_in: - timeline_start = timeline.GetStartFrame() - # Create a clipInfo dictionary with the necessary information - clip_data["recordFrame"] = int(timeline_start + timeline_in) - # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index c679aa062d..b1bde212fe 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -312,6 +312,9 @@ class ClipLoader: # try to get value from options or evaluate key value for `load_to` self.new_timeline = options.get("newTimeline") or bool( "New timeline" in options.get("load_to", "")) + # try to get value from options or evaluate key value for `load_how` + self.sequential_load = options.get("sequentially") or bool( + "Sequentially in order" in options.get("load_how", "")) assert self._populate_data(), str( "Cannot Load selected data, look into database " @@ -352,6 +355,7 @@ class ClipLoader: asset = str(repr_cntx["asset"]) subset = str(repr_cntx["subset"]) representation = str(repr_cntx["representation"]) + self.data["track_name"] = "{}_{}".format(asset, representation) self.data["clip_name"] = "_".join([asset, subset, representation]) self.data["versionData"] = self.context["version"]["data"] # gets file path @@ -383,6 +387,33 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] + def _set_active_track(self): + """ Set active track to `track` """ + track_type = "video" + track_name = self.data["track_name"] + track_exists = False + + # get total track count + track_count = self.active_timeline.GetTrackCount(track_type) + # loop all tracks by track indexes + for track_index in range(1, int(track_count) + 1): + # get current track name + _track_name = self.active_timeline.GetTrackName( + track_type, track_index) + if track_name != _track_name: + continue + track_exists = True + break + + if not track_exists: + self.active_timeline.AddTrack(track_type) + self.active_timeline.SetTrackName( + track_type, + track_index + 1, + track_name + ) + + def load(self): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) @@ -402,8 +433,18 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) - self.timeline_in = int(self.data["assetData"]["clipIn"]) + # handle timeline tracks + self._set_active_track() + # get timeline in + timeline_start = self.active_timeline.GetStartFrame() + if self.sequential_load: + # set timeline start frame + timeline_in = int(timeline_start) + else: + # set timeline start frame + original clip in frame + timeline_in = int( + timeline_start + self.data["assetData"]["clipIn"]) source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -423,7 +464,7 @@ class ClipLoader: self.active_timeline, source_in, source_out, - self.timeline_in + timeline_in ) print("Loading clips: `{}`".format(self.data["clip_name"])) @@ -478,6 +519,16 @@ class TimelineItemLoader(LoaderPlugin): ], default=0, help="Where do you want clips to be loaded?" + ), + qargparse.Choice( + "load_how", + label="How to load clips", + items=[ + "Original timing", + "Sequentially in order" + ], + default="Original timing", + help="Would you like to place it at original timing?" ) ] From ec893d45e3e47ac95ec6f9d3ba16513c70009a78 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 11:50:10 +0200 Subject: [PATCH 0324/1224] updating docs readme to latest python api --- ...0.4.txt => RESOLVE_API_v18.5.1-build6.txt} | 81 +++++++++++++++---- 1 file changed, 65 insertions(+), 16 deletions(-) rename openpype/hosts/resolve/{RESOLVE_API_v18.0.4.txt => RESOLVE_API_v18.5.1-build6.txt} (89%) diff --git a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt similarity index 89% rename from openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt rename to openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt index 98597a12cb..7d1d6edf61 100644 --- a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt +++ b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt @@ -1,4 +1,4 @@ -Updated as of 9 May 2022 +Updated as of 26 May 2023 ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. @@ -19,7 +19,7 @@ DaVinci Resolve scripting requires one of the following to be installed (for all Lua 5.1 Python 2.7 64-bit - Python 3.6 64-bit + Python >= 3.6 64-bit Using a script @@ -171,6 +171,10 @@ Project GetRenderResolutions(format, codec) --> [{Resolution}] # Returns list of resolutions applicable for the given render format (string) and render codec (string). Returns full list of resolutions if no argument is provided. Each element in the list is a dictionary with 2 keys "Width" and "Height". RefreshLUTList() --> Bool # Refreshes LUT List GetUniqueId() --> string # Returns a unique ID for the project item + InsertAudioToCurrentTrackAtPlayhead(mediaPath, --> Bool # Inserts the media specified by mediaPath (string) with startOffsetInSamples (int) and durationInSamples (int) at the playhead on a selected track on the Fairlight page. Returns True if successful, otherwise False. + startOffsetInSamples, durationInSamples) + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for project when supplied presetName (string). Returns true if successful. + ExportCurrentFrameAsStill(filePath) --> Bool # Exports current frame as still to supplied filePath. filePath must end in valid export file format. Returns True if succssful, False otherwise. MediaStorage GetMountedVolumeList() --> [paths...] # Returns list of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. @@ -179,6 +183,7 @@ MediaStorage RevealInStorage(path) --> Bool # Expands and displays given file/folder path in Resolve’s Media Storage. AddItemListToMediaPool(item1, item2, ...) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([items...]) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. + AddItemListToMediaPool([{itemInfo}, ...]) --> [clips...] # Adds list of itemInfos specified as dict of "media", "startFrame" (int), "endFrame" (int) from Media Storage into current Media Pool folder. Returns a list of the MediaPoolItems created. AddClipMattesToMediaPool(MediaPoolItem, [paths], stereoEye) --> Bool # Adds specified media files as mattes for the specified MediaPoolItem. StereoEye is an optional argument for specifying which eye to add the matte to for stereo clips ("left" or "right"). Returns True if successful. AddTimelineMattesToMediaPool([paths]) --> [MediaPoolItems] # Adds specified media files as timeline mattes in current media pool folder. Returns a list of created MediaPoolItems. @@ -189,20 +194,22 @@ MediaPool CreateEmptyTimeline(name) --> Timeline # Adds new timeline with given name. AppendToTimeline(clip1, clip2, ...) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([clips]) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. - AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only). Returns the list of appended timelineItems. + AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only), "trackIndex" (int) and "recordFrame" (int). Returns the list of appended timelineItems. CreateTimelineFromClips(name, clip1, clip2,...) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [clips]) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. - CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int). - ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file and optional importOptions dict, with support for the keys: - # "timelineName": string, specifies the name of the timeline to be created - # "importSourceClips": Bool, specifies whether source clips should be imported, True by default + CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), "recordFrame" (int). + ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file (AAF/EDL/XML/FCPXML/DRT/ADL) and optional importOptions dict, with support for the keys: + # "timelineName": string, specifies the name of the timeline to be created. Not valid for DRT import + # "importSourceClips": Bool, specifies whether source clips should be imported, True by default. Not valid for DRT import # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "importSourceClips" is True - # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False + # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False. Not valid for DRT import # "interlaceProcessing": Bool, specifies whether to enable interlace processing on the imported timeline being created. valid only for AAF import DeleteTimelines([timeline]) --> Bool # Deletes specified timelines in the media pool. GetCurrentFolder() --> Folder # Returns currently selected Folder. SetCurrentFolder(Folder) --> Bool # Sets current folder by given Folder. DeleteClips([clips]) --> Bool # Deletes specified clips or timeline mattes in the media pool + ImportFolderFromFile(filePath, sourceClipsPath="") --> Bool # Returns true if import from given DRB filePath is successful, false otherwise + # sourceClipsPath is a string that specifies a filesystem path to search for source clips if the media is inaccessible in their original path, empty by default DeleteFolders([subfolders]) --> Bool # Deletes specified subfolders in the media pool MoveClips([clips], targetFolder) --> Bool # Moves specified clips to target folder. MoveFolders([folders], targetFolder) --> Bool # Moves specified folders to target folder. @@ -225,6 +232,7 @@ Folder GetSubFolderList() --> [folders...] # Returns a list of subfolders in the folder. GetIsFolderStale() --> bool # Returns true if folder is stale in collaboration mode, false otherwise GetUniqueId() --> string # Returns a unique ID for the media pool folder + Export(filePath) --> bool # Returns true if export of DRB folder to filePath is successful, false otherwise MediaPoolItem GetName() --> string # Returns the clip name. @@ -257,6 +265,8 @@ MediaPoolItem UnlinkProxyMedia() --> Bool # Unlinks any proxy media associated with clip. ReplaceClip(filePath) --> Bool # Replaces the underlying asset and metadata of MediaPoolItem with the specified absolute clip path. GetUniqueId() --> string # Returns a unique ID for the media pool item + TranscribeAudio() --> Bool # Transcribes audio of the MediaPoolItem. Returns True if successful; False otherwise + ClearTranscription() --> Bool # Clears audio transcription of the MediaPoolItem. Returns True if successful; False otherwise. Timeline GetName() --> string # Returns the timeline name. @@ -266,6 +276,23 @@ Timeline SetStartTimecode(timecode) --> Bool # Set the start timecode of the timeline to the string 'timecode'. Returns true when the change is successful, false otherwise. GetStartTimecode() --> string # Returns the start timecode for the timeline. GetTrackCount(trackType) --> int # Returns the number of tracks for the given track type ("audio", "video" or "subtitle"). + AddTrack(trackType, optionalSubTrackType) --> Bool # Adds track of trackType ("video", "subtitle", "audio"). Second argument optionalSubTrackType is required for "audio" + # optionalSubTrackType can be one of {"mono", "stereo", "5.1", "5.1film", "7.1", "7.1film", "adaptive1", ... , "adaptive24"} + DeleteTrack(trackType, trackIndex) --> Bool # Deletes track of trackType ("video", "subtitle", "audio") and given trackIndex. 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackEnable(trackType, trackIndex, Bool) --> Bool # Enables/Disables track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackEnabled(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is enabled and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackLock(trackType, trackIndex, Bool) --> Bool # Locks/Unlocks track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackLocked(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is locked and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + DeleteClips([timelineItems], Bool) --> Bool # Deletes specified TimelineItems from the timeline, performing ripple delete if the second argument is True. Second argument is optional (The default for this is False) + SetClipsLinked([timelineItems], Bool) --> Bool # Links or unlinks the specified TimelineItems depending on second argument. GetItemListInTrack(trackType, index) --> [items...] # Returns a list of timeline items on that track (based on trackType and index). 1 <= index <= GetTrackCount(trackType). AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) @@ -301,7 +328,7 @@ Timeline # "sourceClipsFolders": string, list of Media Pool folder objects to search for source clips if the media is not present in current folder Export(fileName, exportType, exportSubtype) --> Bool # Exports timeline to 'fileName' as per input exportType & exportSubtype format. - # Refer to section "Looking up timeline exports properties" for information on the parameters. + # Refer to section "Looking up timeline export properties" for information on the parameters. GetSetting(settingName) --> string # Returns value of timeline setting (indicated by settingName : string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets timeline setting (indicated by settingName : string) to the value (settingValue : string). Check the section below for more information. InsertGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a generator (indicated by generatorName : string) into the timeline. @@ -313,6 +340,8 @@ Timeline GrabStill() --> galleryStill # Grabs still from the current video clip. Returns a GalleryStill object. GrabAllStills(stillFrameSource) --> [galleryStill] # Grabs stills from all the clips of the timeline at 'stillFrameSource' (1 - First frame, 2 - Middle frame). Returns the list of GalleryStill objects. GetUniqueId() --> string # Returns a unique ID for the timeline + CreateSubtitlesFromAudio() --> Bool # Creates subtitles from audio for the timeline. Returns True on success, False otherwise. + DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. TimelineItem GetName() --> string # Returns the item name. @@ -362,6 +391,7 @@ TimelineItem GetStereoLeftFloatingWindowParams() --> {keyframes...} # For the LEFT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetStereoRightFloatingWindowParams() --> {keyframes...} # For the RIGHT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetNumNodes() --> int # Returns the number of nodes in the current graph for the timeline item + ApplyArriCdlLut() --> Bool # Applies ARRI CDL and LUT. Returns True if successful, False otherwise. SetLUT(nodeIndex, lutPath) --> Bool # Sets LUT on the node mapping the node index provided, 1 <= nodeIndex <= total number of nodes. # The lutPath can be an absolute path, or a relative path (based off custom LUT paths or the master LUT path). # The operation is successful for valid lut paths that Resolve has already discovered (see Project.RefreshLUTList). @@ -376,8 +406,16 @@ TimelineItem SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. + SetClipEnabled(Bool) --> Bool # Sets clip enabled based on argument. + GetClipEnabled() --> Bool # Gets clip enabled status. UpdateSidecar() --> Bool # Updates sidecar file for BRAW clips or RMD file for R3D clips. GetUniqueId() --> string # Returns a unique ID for the timeline item + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for clip when supplied presetName (string). Returns true if successful. + GetNodeLabel(nodeIndex) --> string # Returns the label of the node at nodeIndex. + CreateMagicMask(mode) --> Bool # Returns True if magic mask was created successfully, False otherwise. mode can "F" (forward), "B" (backward), or "BI" (bidirection) + RegenerateMagicMask() --> Bool # Returns True if magic mask was regenerated successfully, False otherwise. + Stabilize() --> Bool # Returns True if stabilization was successful, False otherwise + SmartReframe() --> Bool # Performs Smart Reframe. Returns True if successful, False otherwise. Gallery GetAlbumName(galleryStillAlbum) --> string # Returns the name of the GalleryStillAlbum object 'galleryStillAlbum'. @@ -422,9 +460,11 @@ Invoke "Project:SetSetting", "Timeline:SetSetting" or "MediaPoolItem:SetClipProp ensure the success of the operation. You can troubleshoot the validity of keys and values by setting the desired result from the UI and checking property snapshots before and after the change. The following Project properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 0 and 3 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"superScale" - the property value is an enumerated integer between 0 and 4 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = Project:GetSetting('superScale') and Project:SetSetting('superScale', x) +• for '2x Enhanced' --> Project:SetSetting('superScale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] "timelineFrameRate" - the property value is one of the frame rates available to the user in project settings under "Timeline frame rate" option. Drop Frame can be configured for supported frame rates by appending the frame rate with "DF", e.g. "29.97 DF" will enable drop frame and "29.97" will disable drop frame @@ -432,9 +472,11 @@ Affects: • x = Project:GetSetting('timelineFrameRate') and Project:SetSetting('timelineFrameRate', x) The following Clip properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 1 and 3 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"Super Scale" - the property value is an enumerated integer between 1 and 4 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = MediaPoolItem:GetClipProperty('Super Scale') and MediaPoolItem:SetClipProperty('Super Scale', x) +• for '2x Enhanced' --> MediaPoolItem:SetClipProperty('Super Scale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] Looking up Render Settings @@ -478,11 +520,6 @@ exportType can be one of the following constants: - resolve.EXPORT_DRT - resolve.EXPORT_EDL - resolve.EXPORT_FCP_7_XML - - resolve.EXPORT_FCPXML_1_3 - - resolve.EXPORT_FCPXML_1_4 - - resolve.EXPORT_FCPXML_1_5 - - resolve.EXPORT_FCPXML_1_6 - - resolve.EXPORT_FCPXML_1_7 - resolve.EXPORT_FCPXML_1_8 - resolve.EXPORT_FCPXML_1_9 - resolve.EXPORT_FCPXML_1_10 @@ -492,6 +529,8 @@ exportType can be one of the following constants: - resolve.EXPORT_TEXT_TAB - resolve.EXPORT_DOLBY_VISION_VER_2_9 - resolve.EXPORT_DOLBY_VISION_VER_4_0 + - resolve.EXPORT_DOLBY_VISION_VER_5_1 + - resolve.EXPORT_OTIO exportSubtype can be one of the following enums: - resolve.EXPORT_NONE - resolve.EXPORT_AAF_NEW @@ -504,6 +543,16 @@ When exportType is resolve.EXPORT_AAF, valid exportSubtype values are resolve.EX When exportType is resolve.EXPORT_EDL, valid exportSubtype values are resolve.EXPORT_CDL, resolve.EXPORT_SDL, resolve.EXPORT_MISSING_CLIPS and resolve.EXPORT_NONE. Note: Replace 'resolve.' when using the constants above, if a different Resolve class instance name is used. +Unsupported exportType types +--------------------------------- +Starting with DaVinci Resolve 18.1, the following export types are not supported: + - resolve.EXPORT_FCPXML_1_3 + - resolve.EXPORT_FCPXML_1_4 + - resolve.EXPORT_FCPXML_1_5 + - resolve.EXPORT_FCPXML_1_6 + - resolve.EXPORT_FCPXML_1_7 + + Looking up Timeline item properties ----------------------------------- This section covers additional notes for the function "TimelineItem:SetProperty" and "TimelineItem:GetProperty". These functions are used to get and set properties mentioned. From 2850df81b9c73b6d9ffabebf7b8f9a28b5b9c959 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 2 Oct 2023 17:52:34 +0800 Subject: [PATCH 0325/1224] 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 fac33119ec0e7a7087b053b95711b85dd13dc791 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 13:28:57 +0200 Subject: [PATCH 0326/1224] Enable only in Fusion 18.5+ --- openpype/hosts/fusion/plugins/load/load_usd.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index f12fbd5ed0..beab0c8ecf 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -7,6 +7,7 @@ from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) +from openpype.hosts.fusion.api.lib import get_fusion_module class FusionLoadUSD(load.LoaderPlugin): @@ -26,6 +27,19 @@ class FusionLoadUSD(load.LoaderPlugin): tool_type = "uLoader" + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(FusionLoadUSD, cls).apply_settings(project_settings, + system_settings) + if cls.enabled: + # Enable only in Fusion 18.5+ + fusion = get_fusion_module() + version = fusion.GetVersion() + major = version[1] + minor = version[2] + is_usd_supported = (major, minor) >= (18, 5) + cls.enabled = is_usd_supported + def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: From 277a0baa7bbe89b2a58fbe9568271e4d5f72e86b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 13:29:49 +0200 Subject: [PATCH 0327/1224] Hound --- openpype/hosts/fusion/plugins/load/load_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py index beab0c8ecf..4f1813a646 100644 --- a/openpype/hosts/fusion/plugins/load/load_usd.py +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -29,7 +29,7 @@ class FusionLoadUSD(load.LoaderPlugin): @classmethod def apply_settings(cls, project_settings, system_settings): - super(FusionLoadUSD, cls).apply_settings(project_settings, + super(FusionLoadUSD, cls).apply_settings(project_settings, system_settings) if cls.enabled: # Enable only in Fusion 18.5+ From 6b3aa6a247c895507771d414c653757634628727 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 2 Oct 2023 12:45:18 +0100 Subject: [PATCH 0328/1224] 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 0329/1224] 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 df5fc6154dcd26cda86247e484c4798aae4a099f Mon Sep 17 00:00:00 2001 From: Ember Light <49758407+EmberLightVFX@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:23:42 +0200 Subject: [PATCH 0330/1224] Change name from Validate Saver to Validate Asset Co-authored-by: Roy Nieterau --- .../hosts/fusion/plugins/publish/validate_saver_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py index b43a5023fa..efa7295d11 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py @@ -63,7 +63,7 @@ class ValidateSaverResolution( """Validate that the saver input resolution matches the asset resolution""" order = pyblish.api.ValidatorOrder - label = "Validate Saver Resolution" + label = "Validate Asset Resolution" families = ["render"] hosts = ["fusion"] optional = True From 333c282eba0467883e3709245e8f0b763537155c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 2 Oct 2023 14:24:02 +0200 Subject: [PATCH 0331/1224] 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 ffeb0282b93b05a1d345bfa39928c195d5f1ff74 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 14:39:27 +0200 Subject: [PATCH 0332/1224] Restore formatting of non-modified code --- openpype/hosts/fusion/api/lib.py | 75 ++++++++++++-------------------- 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 19db484856..8a18080393 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -21,15 +21,8 @@ from openpype.pipeline.context_tools import get_current_project_asset self = sys.modules[__name__] self._project = None - -def update_frame_range( - start, - end, - comp=None, - set_render_range=True, - handle_start=0, - handle_end=0, -): +def update_frame_range(start, end, comp=None, set_render_range=True, + handle_start=0, handle_end=0): """Set Fusion comp's start and end frame range Args: @@ -55,17 +48,15 @@ def update_frame_range( attrs = { "COMPN_GlobalStart": start - handle_start, - "COMPN_GlobalEnd": end + handle_end, + "COMPN_GlobalEnd": end + handle_end } # set frame range if set_render_range: - attrs.update( - { - "COMPN_RenderStart": start, - "COMPN_RenderEnd": end, - } - ) + attrs.update({ + "COMPN_RenderStart": start, + "COMPN_RenderEnd": end + }) with comp_lock_and_undo_chunk(comp): comp.SetAttrs(attrs) @@ -78,13 +69,9 @@ def set_asset_framerange(): end = asset_doc["data"]["frameEnd"] handle_start = asset_doc["data"]["handleStart"] handle_end = asset_doc["data"]["handleEnd"] - update_frame_range( - start, - end, - set_render_range=True, - handle_start=handle_start, - handle_end=handle_end, - ) + update_frame_range(start, end, set_render_range=True, + handle_start=handle_start, + handle_end=handle_end) def set_asset_resolution(): @@ -94,15 +81,12 @@ def set_asset_resolution(): height = asset_doc["data"]["resolutionHeight"] comp = get_current_comp() - print( - "Setting comp frame format resolution to {}x{}".format(width, height) - ) - comp.SetPrefs( - { - "Comp.FrameFormat.Width": width, - "Comp.FrameFormat.Height": height, - } - ) + print("Setting comp frame format resolution to {}x{}".format(width, + height)) + comp.SetPrefs({ + "Comp.FrameFormat.Width": width, + "Comp.FrameFormat.Height": height, + }) def validate_comp_prefs(comp=None, force_repair=False): @@ -123,7 +107,7 @@ def validate_comp_prefs(comp=None, force_repair=False): "data.fps", "data.resolutionWidth", "data.resolutionHeight", - "data.pixelAspect", + "data.pixelAspect" ] asset_doc = get_current_project_asset(fields=fields) asset_data = asset_doc["data"] @@ -140,7 +124,7 @@ def validate_comp_prefs(comp=None, force_repair=False): ("resolutionWidth", "Width", "Resolution Width"), ("resolutionHeight", "Height", "Resolution Height"), ("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"), - ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y"), + ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y") ] invalid = [] @@ -148,9 +132,9 @@ def validate_comp_prefs(comp=None, force_repair=False): asset_value = asset_data[key] comp_value = comp_frame_format_prefs.get(comp_key) if asset_value != comp_value: - invalid_msg = "{} {} should be {}".format( - label, comp_value, asset_value - ) + invalid_msg = "{} {} should be {}".format(label, + comp_value, + asset_value) invalid.append(invalid_msg) if not force_repair: @@ -161,8 +145,7 @@ def validate_comp_prefs(comp=None, force_repair=False): pref=label, value=comp_value, asset_name=asset_doc["name"], - asset_value=asset_value, - ) + asset_value=asset_value) ) if invalid: @@ -299,13 +282,11 @@ def maintained_selection(comp=None): @contextlib.contextmanager -def maintained_comp_range( - comp=None, - global_start=True, - global_end=True, - render_start=True, - render_end=True, -): +def maintained_comp_range(comp=None, + global_start=True, + global_end=True, + render_start=True, + render_end=True): """Reset comp frame ranges from before the context after the context""" if comp is None: comp = get_current_comp() @@ -349,7 +330,7 @@ def get_frame_path(path): filename, ext = os.path.splitext(path) # Find a final number group - match = re.match(".*?([0-9]+)$", filename) + match = re.match('.*?([0-9]+)$', filename) if match: padding = len(match.group(1)) # remove number from end since fusion From 80febccf0ef2162623d02a83b362b85df7d33c8b Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 14:41:54 +0200 Subject: [PATCH 0333/1224] hound --- openpype/hosts/fusion/api/lib.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 0393c1f7d5..85f9c54a73 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -21,6 +21,7 @@ from openpype.pipeline.context_tools import get_current_project_asset self = sys.modules[__name__] self._project = None + def update_frame_range(start, end, comp=None, set_render_range=True, handle_start=0, handle_end=0): """Set Fusion comp's start and end frame range @@ -70,8 +71,8 @@ def set_asset_framerange(): handle_start = asset_doc["data"]["handleStart"] handle_end = asset_doc["data"]["handleEnd"] update_frame_range(start, end, set_render_range=True, - handle_start=handle_start, - handle_end=handle_end) + handle_start=handle_start, + handle_end=handle_end) def set_asset_resolution(): @@ -133,8 +134,8 @@ def validate_comp_prefs(comp=None, force_repair=False): comp_value = comp_frame_format_prefs.get(comp_key) if asset_value != comp_value: invalid_msg = "{} {} should be {}".format(label, - comp_value, - asset_value) + comp_value, + asset_value) invalid.append(invalid_msg) if not force_repair: @@ -166,7 +167,6 @@ def validate_comp_prefs(comp=None, force_repair=False): from . import menu from openpype.widgets import popup from openpype.style import load_stylesheet - dialog = popup.Popup(parent=menu.menu) dialog.setWindowTitle("Fusion comp has invalid configuration") From 73a122b79a1069e767227307cb30dc23446f8c61 Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 15:09:30 +0200 Subject: [PATCH 0334/1224] 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 0335/1224] 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 8c508b4b00082e7f0427fa929a5d2e0d8be19d99 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 15:45:38 +0200 Subject: [PATCH 0336/1224] removing testing scripts --- .../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 88a1f97ad595000b6a440643b61cbcd3b8f659c9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 2 Oct 2023 15:48:27 +0200 Subject: [PATCH 0337/1224] resolve: improving loading --- openpype/hosts/resolve/api/lib.py | 54 +++++++++++++++++----------- openpype/hosts/resolve/api/plugin.py | 43 ++++------------------ 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 8f7eba8a90..22be929412 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -6,7 +6,10 @@ import contextlib from opentimelineio import opentime from openpype.lib import Logger -from openpype.pipeline.editorial import is_overlapping_otio_ranges +from openpype.pipeline.editorial import ( + is_overlapping_otio_ranges, + frames_to_timecode +) from ..otio import davinci_export as otio_export @@ -243,11 +246,13 @@ def get_media_pool_item(fpath, root: object = None) -> object: return None -def create_timeline_item(media_pool_item: object, - timeline: object = None, - source_start: int = None, - source_end: int = None, - timeline_in: int = None) -> object: +def create_timeline_item( + media_pool_item: object, + source_start: int, + source_end: int, + timeline_in: int, + timeline: object = None +) -> object: """ Add media pool item to current or defined timeline. @@ -267,20 +272,24 @@ def create_timeline_item(media_pool_item: object, clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() + # timing variables + fps = project.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data - clip_data = {"mediaPoolItem": media_pool_item} - - # add source time range if input was given - if source_start is not None: - clip_data["startFrame"] = source_start - if source_end is not None: - clip_data["endFrame"] = source_end - - # Create a clipInfo dictionary with the necessary information - clip_data["recordFrame"] = timeline_in + clip_data = { + "mediaPoolItem": media_pool_item, + "startFrame": source_start, + "endFrame": source_end, + "recordFrame": timeline_in, + } + print("clip_data", "_" * 50) + print(media_pool_item.GetName()) print(clip_data) # add to timeline @@ -289,10 +298,15 @@ def create_timeline_item(media_pool_item: object, output_timeline_item = get_timeline_item( media_pool_item, timeline) - assert output_timeline_item, AssertionError( - "Track Item with name `{}` doesn't exist on the timeline: `{}`".format( - clip_name, timeline.GetName() - )) + assert output_timeline_item, AssertionError(( + "Clip name '{}' was't created on the timeline: '{}' \n\n" + "Please check if the clip is in the media pool or if the timeline \n" + "is having activated correct track name, or if it is not already \n" + "having any clip add place on the timeline in: '{}' out: '{}'. \n\n" + "Clip data: {}" + ).format( + clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data + )) return output_timeline_item diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index b1bde212fe..1c817d8e0d 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -355,7 +355,6 @@ class ClipLoader: asset = str(repr_cntx["asset"]) subset = str(repr_cntx["subset"]) representation = str(repr_cntx["representation"]) - self.data["track_name"] = "{}_{}".format(asset, representation) self.data["clip_name"] = "_".join([asset, subset, representation]) self.data["versionData"] = self.context["version"]["data"] # gets file path @@ -387,32 +386,6 @@ class ClipLoader: asset_name = self.context["representation"]["context"]["asset"] self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def _set_active_track(self): - """ Set active track to `track` """ - track_type = "video" - track_name = self.data["track_name"] - track_exists = False - - # get total track count - track_count = self.active_timeline.GetTrackCount(track_type) - # loop all tracks by track indexes - for track_index in range(1, int(track_count) + 1): - # get current track name - _track_name = self.active_timeline.GetTrackName( - track_type, track_index) - if track_name != _track_name: - continue - track_exists = True - break - - if not track_exists: - self.active_timeline.AddTrack(track_type) - self.active_timeline.SetTrackName( - track_type, - track_index + 1, - track_name - ) - def load(self): # create project bin for the media to be imported into @@ -420,7 +393,6 @@ class ClipLoader: # 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 @@ -433,9 +405,6 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) - # handle timeline tracks - self._set_active_track() - # get timeline in timeline_start = self.active_timeline.GetStartFrame() if self.sequential_load: @@ -454,17 +423,17 @@ class ClipLoader: source_out -= handle_end # include handles - if self.with_handles: - source_in -= handle_start - source_out += handle_end + if not self.with_handles: + source_in += handle_start + source_out -= handle_end # make track item from source in bin as item timeline_item = lib.create_timeline_item( media_pool_item, - self.active_timeline, source_in, source_out, - timeline_in + timeline_in, + self.active_timeline, ) print("Loading clips: `{}`".format(self.data["clip_name"])) @@ -504,7 +473,7 @@ class TimelineItemLoader(LoaderPlugin): """ options = [ - qargparse.Toggle( + qargparse.Boolean( "handles", label="Include handles", default=0, From bd31dbaf35d9f2a9c054223827d58cf34ebe14dd Mon Sep 17 00:00:00 2001 From: Jacob Danell Date: Mon, 2 Oct 2023 16:12:20 +0200 Subject: [PATCH 0338/1224] 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 0339/1224] 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 0340/1224] 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 0341/1224] 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 0342/1224] 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 0343/1224] 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 0344/1224] 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 0345/1224] 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 0346/1224] 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 0347/1224] 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 0348/1224] 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 be09038e222f833a2eb3290006f08d02ea88d9a3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 3 Oct 2023 14:54:54 +0800 Subject: [PATCH 0349/1224] separating tyMesh and tyType into two representations --- .../hosts/max/plugins/publish/extract_tycache.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 5fa8642809..f4a5e0f4a6 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -66,6 +66,17 @@ class ExtractTyCache(publish.Extractor): instance.data["representations"].append(representation) self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") + # Get the tyMesh filename for extraction + mesh_filename = "{}__tyMesh.tyc".format(instance.name) + mesh_repres = { + 'name': 'tyMesh', + 'ext': 'tyc', + 'files': mesh_filename, + "stagingDir": stagingdir + } + instance.data["representations"].append(mesh_repres) + self.log.info(f"Extracted instance '{instance.name}' to: {mesh_filename}") + def get_file(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. @@ -74,7 +85,6 @@ class ExtractTyCache(publish.Extractor): Actual File Output from tyFlow in tyCache format: __tyPart_.tyc - __tyMesh.tyc e.g. tycacheMain__tyPart_00000.tyc @@ -92,7 +102,6 @@ class ExtractTyCache(publish.Extractor): for frame in range(int(start_frame), int(end_frame) + 1): filename = "{}__tyPart_{:05}.tyc".format(instance.name, frame) filenames.append(filename) - filenames.append("{}__tyMesh.tyc".format(instance.name)) return filenames def export_particle(self, members, start, end, From 74a6d33baa3061363df4795c44c0547af2fb505d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 3 Oct 2023 14:55:54 +0800 Subject: [PATCH 0350/1224] hound --- openpype/hosts/max/plugins/publish/extract_tycache.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index f4a5e0f4a6..56fd39406e 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -75,7 +75,8 @@ class ExtractTyCache(publish.Extractor): "stagingDir": stagingdir } instance.data["representations"].append(mesh_repres) - self.log.info(f"Extracted instance '{instance.name}' to: {mesh_filename}") + self.log.info( + f"Extracted instance '{instance.name}' to: {mesh_filename}") def get_file(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. From 7cbe5e8f6259fae8134a108799a73a64ceb0a61a Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 15:39:48 +0800 Subject: [PATCH 0351/1224] 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 0352/1224] 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 0353/1224] 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 0354/1224] 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 0355/1224] 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 0356/1224] 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 0357/1224] 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 0358/1224] 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 0359/1224] 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 0360/1224] 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 0361/1224] 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 2317ab057f56138154308dc83bf5f510e6514052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 3 Oct 2023 13:20:07 +0200 Subject: [PATCH 0362/1224] Update openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py Co-authored-by: Roy Nieterau --- .../traypublisher/plugins/publish/validate_colorspace_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py index 7de8881321..2a9b2040d1 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py @@ -60,7 +60,7 @@ class ValidateColorspaceLook(pyblish.api.InstancePlugin, if not_set_keys: message = ( - f"Colorspace look attributes are not set: " + "Colorspace look attributes are not set: " f"{', '.join(not_set_keys)}" ) raise PublishValidationError( From 774050eff300c28ea33ef58f5f5227cf66d94ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 3 Oct 2023 13:27:14 +0200 Subject: [PATCH 0363/1224] Update openpype/scripts/ocio_wrapper.py Co-authored-by: Roy Nieterau --- openpype/scripts/ocio_wrapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 2bd25002c5..092d94623f 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -132,11 +132,11 @@ def _get_colorspace_data(config_path): roles = config.getRoles() if roles: colorspace_data.update({ - role[0]: { + role: { "type": "role", - "colorspace": role[1] + "colorspace": colorspace } - for role in roles + for (role, colorspace) in roles }) return colorspace_data From c30eb6ed4d9eea479e4bfd2f7235d941a0350f08 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 3 Oct 2023 13:32:39 +0200 Subject: [PATCH 0364/1224] improving creator def aggregation cycle in validator --- .../plugins/publish/validate_colorspace_look.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py index 7de8881321..c24bd6ee11 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py @@ -39,22 +39,21 @@ class ValidateColorspaceLook(pyblish.api.InstancePlugin, "direction", "interpolation" ] + creator_defs_by_key = {_def.key: _def.label for _def in creator_defs} + not_set_keys = [] for key in check_keys: if ociolook_item[key]: # key is set and it is correct continue - def_label = next( - (d_.label for d_ in creator_defs if key == d_.key), - None - ) + def_label = creator_defs_by_key.get(key) + if not def_label: - def_attrs = [(d_.key, d_.label) for d_ in creator_defs] # raise since key is not recognized by creator defs raise KeyError( f"Colorspace look attribute '{key}' is not " - f"recognized by creator attributes: {def_attrs}" + f"recognized by creator attributes: {creator_defs_by_key}" ) not_set_keys.append(def_label) From 71838b05153576235b969e915ac716fac88ce97a Mon Sep 17 00:00:00 2001 From: Kayla Date: Tue, 3 Oct 2023 20:06:14 +0800 Subject: [PATCH 0365/1224] 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 0366/1224] 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 0367/1224] :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 0368/1224] :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 0369/1224] 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 2ccc2a101cfd304d1767e8f667fb6db3a73c2cab Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 3 Oct 2023 22:10:51 +0200 Subject: [PATCH 0370/1224] Add save current file button + "Save" shortcut when menu is active --- openpype/hosts/resolve/api/menu.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index b3717e01ea..fd5a4cec78 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -1,13 +1,14 @@ import os import sys -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.utils import host_tools +from openpype.pipeline import registered_host from .pipeline import ( publish, - launch_workfiles_app + launch_workfiles_app, ) @@ -54,6 +55,7 @@ class OpenPypeMenu(QtWidgets.QWidget): ) self.setWindowTitle("OpenPype") + save_current_btn = QtWidgets.QPushButton("Save current file", self) workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) create_btn = QtWidgets.QPushButton("Create ...", self) publish_btn = QtWidgets.QPushButton("Publish ...", self) @@ -75,6 +77,10 @@ class OpenPypeMenu(QtWidgets.QWidget): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) + layout.addWidget(save_current_btn) + + layout.addWidget(Spacer(15, self)) + layout.addWidget(workfiles_btn) layout.addWidget(create_btn) layout.addWidget(publish_btn) @@ -99,6 +105,8 @@ class OpenPypeMenu(QtWidgets.QWidget): self.setLayout(layout) + save_current_btn.clicked.connect(self.on_save_current_clicked) + save_current_btn.setShortcut(QtGui.QKeySequence.Save) workfiles_btn.clicked.connect(self.on_workfile_clicked) create_btn.clicked.connect(self.on_create_clicked) publish_btn.clicked.connect(self.on_publish_clicked) @@ -111,6 +119,15 @@ class OpenPypeMenu(QtWidgets.QWidget): # reset_resolution_btn.clicked.connect(self.on_set_resolution_clicked) experimental_btn.clicked.connect(self.on_experimental_clicked) + def on_save_current_clicked(self): + host = registered_host() + current_file = host.current_file() + if not current_file: + return + + print(f"Saving current file to: {current_file}") + host.save_file(current_file) + def on_workfile_clicked(self): print("Clicked Workfile") launch_workfiles_app() From 02b64a40f1f0181c684fee182a72f723c680bb34 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 4 Oct 2023 03:24:55 +0000 Subject: [PATCH 0371/1224] [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 0372/1224] 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 fe11a3aed27cf3b0b215209c2148fa4167d52e96 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 4 Oct 2023 08:50:52 +0200 Subject: [PATCH 0373/1224] Revert cosmetic change --- openpype/hosts/resolve/api/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index fd5a4cec78..024b367235 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -8,7 +8,7 @@ from openpype.pipeline import registered_host from .pipeline import ( publish, - launch_workfiles_app, + launch_workfiles_app ) From 32b4fc5f645c638a9d11b3e00a4283a80a865b17 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 16:10:19 +0800 Subject: [PATCH 0374/1224] 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 0375/1224] 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 0376/1224] 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 0377/1224] 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 1d0e55aa833d99180b99cbbd718954933c5103b2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 13:00:42 +0200 Subject: [PATCH 0378/1224] improving colorspace categorization also adding new abstract function for returning all settings options nicely labelled --- .../plugins/create/create_colorspace_look.py | 13 ++-- .../publish/collect_explicit_colorspace.py | 29 ++----- openpype/pipeline/colorspace.py | 77 ++++++++++++++++++- openpype/scripts/ocio_wrapper.py | 33 ++++---- 4 files changed, 101 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index 62ecc391f6..3f3fa5348a 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -148,13 +148,12 @@ This creator publishes color space look file (LUT). if config_data: filepath = config_data["path"] - config_items = colorspace.get_ocio_config_colorspaces(filepath) - - self.colorspace_items.extend(( - (name, f"{name} [{data_['type']}]") - for name, data_ in config_items.items() - if data_.get("type") == "colorspace" - )) + labeled_colorspaces = colorspace.get_labeled_colorspaces( + filepath, + include_aliases=True, + include_roles=True + ) + self.colorspace_items.extend(labeled_colorspaces) self.enabled = True def _get_subset(self, asset_doc, variant, project_name, task_name=None): diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index 08479b8363..06ceac5923 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -50,30 +50,13 @@ class CollectColorspace(pyblish.api.InstancePlugin, ) if config_data: - filepath = config_data["path"] - config_items = colorspace.get_ocio_config_colorspaces(filepath) - aliases = set() - for _, value_ in config_items.items(): - if value_.get("type") != "colorspace": - continue - if not value_.get("aliases"): - continue - for alias in value_.get("aliases"): - aliases.add(alias) - - colorspaces = { - name for name, data_ in config_items.items() - if data_.get("type") == "colorspace" - } - - cls.colorspace_items.extend(( - (name, f"{name} [colorspace]") for name in colorspaces - )) - if aliases: - cls.colorspace_items.extend(( - (name, f"{name} [alias]") for name in aliases - )) + labeled_colorspaces = colorspace.get_labeled_colorspaces( + filepath, + include_aliases=True, + include_roles=True + ) + cls.colorspace_items.extend(labeled_colorspaces) cls.enabled = True @classmethod diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 2800050496..39fdef046b 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -356,7 +356,10 @@ def parse_colorspace_from_filepath( "Must provide `config_path` if `colorspaces` is not provided." ) - colorspaces = colorspaces or get_ocio_config_colorspaces(config_path) + colorspaces = ( + colorspaces + or get_ocio_config_colorspaces(config_path)["colorspace"] + ) underscored_colorspaces = { key.replace(" ", "_"): key for key in colorspaces if " " in key @@ -393,7 +396,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): Returns: bool: True if exists """ - colorspaces = get_ocio_config_colorspaces(config_path) + colorspaces = get_ocio_config_colorspaces(config_path)["colorspace"] if colorspace_name not in colorspaces: raise KeyError( "Missing colorspace '{}' in config file '{}'".format( @@ -530,6 +533,76 @@ def get_ocio_config_colorspaces(config_path): return CachedData.ocio_config_colorspaces[config_path] +def get_labeled_colorspaces( + config_path, + include_aliases=False, + include_looks=False, + include_roles=False, + +): + """Get all colorspace data with labels + + Wrapper function for aggregating all names and its families. + Families can be used for building menu and submenus in gui. + + Args: + config_path (str): path leading to config.ocio file + include_aliases (bool): include aliases in result + include_looks (bool): include looks in result + include_roles (bool): include roles in result + + Returns: + list[tuple[str,str]]: colorspace and family in couple + """ + config_items = get_ocio_config_colorspaces(config_path) + labeled_colorspaces = [] + aliases = set() + colorspaces = set() + looks = set() + roles = set() + for items_type, colorspace_items in config_items.items(): + if items_type == "colorspace": + for color_name, color_data in colorspace_items.items(): + if color_data.get("aliases"): + aliases.update([ + "{} ({})".format(alias_name, color_name) + for alias_name in color_data["aliases"] + ]) + colorspaces.add(color_name) + elif items_type == "look": + looks.update([ + "{} ({})".format(name, role_data["process_space"]) + for name, role_data in colorspace_items.items() + ]) + elif items_type == "role": + roles.update([ + "{} ({})".format(name, role_data["colorspace"]) + for name, role_data in colorspace_items.items() + ]) + + if roles and include_roles: + labeled_colorspaces.extend(( + (name, f"[role] {name}") for name in roles + )) + + labeled_colorspaces.extend(( + (name, f"[colorspace] {name}") for name in colorspaces + )) + + if aliases and include_aliases: + labeled_colorspaces.extend(( + (name, f"[alias] {name}") for name in aliases + )) + + if looks and include_looks: + labeled_colorspaces.extend(( + (name, f"[look] {name}") for name in looks + )) + + + return labeled_colorspaces + + # TODO: remove this in future - backward compatibility @deprecated("_get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 092d94623f..be21f0984f 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -107,37 +107,32 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) colorspace_data = { - color.getName(): { - "type": "colorspace", - "family": color.getFamily(), - "categories": list(color.getCategories()), - "aliases": list(color.getAliases()), - "equalitygroup": color.getEqualityGroup(), + "colorspace": { + color.getName(): { + "family": color.getFamily(), + "categories": list(color.getCategories()), + "aliases": list(color.getAliases()), + "equalitygroup": color.getEqualityGroup(), + } + for color in config.getColorSpaces() } - for color in config.getColorSpaces() } # add looks looks = config.getLooks() if looks: - colorspace_data.update({ - look.getName(): { - "type": "look", - "process_space": look.getProcessSpace() - } + colorspace_data["look"] = { + look.getName(): {"process_space": look.getProcessSpace()} for look in looks - }) + } # add roles roles = config.getRoles() if roles: - colorspace_data.update({ - role: { - "type": "role", - "colorspace": colorspace - } + colorspace_data["role"] = { + role: {"colorspace": colorspace} for (role, colorspace) in roles - }) + } return colorspace_data From af3ebd190cd9c2cbf71eaa3c16bbd7bee4632ed5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 13:24:18 +0200 Subject: [PATCH 0379/1224] fix in labeling --- openpype/pipeline/colorspace.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 39fdef046b..c456baa70f 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -565,39 +565,33 @@ def get_labeled_colorspaces( for color_name, color_data in colorspace_items.items(): if color_data.get("aliases"): aliases.update([ - "{} ({})".format(alias_name, color_name) + (alias_name, "[alias] {} ({})".format(alias_name, color_name)) for alias_name in color_data["aliases"] ]) colorspaces.add(color_name) elif items_type == "look": looks.update([ - "{} ({})".format(name, role_data["process_space"]) + (name, "[look] {} ({})".format(name, role_data["process_space"])) for name, role_data in colorspace_items.items() ]) elif items_type == "role": roles.update([ - "{} ({})".format(name, role_data["colorspace"]) + (name, "[role] {} ({})".format(name, role_data["colorspace"])) for name, role_data in colorspace_items.items() ]) if roles and include_roles: - labeled_colorspaces.extend(( - (name, f"[role] {name}") for name in roles - )) + labeled_colorspaces.extend(roles) labeled_colorspaces.extend(( (name, f"[colorspace] {name}") for name in colorspaces )) if aliases and include_aliases: - labeled_colorspaces.extend(( - (name, f"[alias] {name}") for name in aliases - )) + labeled_colorspaces.extend(aliases) if looks and include_looks: - labeled_colorspaces.extend(( - (name, f"[look] {name}") for name in looks - )) + labeled_colorspaces.extend(looks) return labeled_colorspaces From 919540038ff969c966acb7fe098cc64da77363d7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 13:24:41 +0200 Subject: [PATCH 0380/1224] unit testing of labeling --- .../pipeline/test_colorspace_labels.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/unit/openpype/pipeline/test_colorspace_labels.py diff --git a/tests/unit/openpype/pipeline/test_colorspace_labels.py b/tests/unit/openpype/pipeline/test_colorspace_labels.py new file mode 100644 index 0000000000..a135c3258b --- /dev/null +++ b/tests/unit/openpype/pipeline/test_colorspace_labels.py @@ -0,0 +1,81 @@ +import unittest +from unittest.mock import patch +from openpype.pipeline.colorspace import get_labeled_colorspaces + + +class TestGetLabeledColorspaces(unittest.TestCase): + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_returns_list_of_tuples(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspace': { + 'sRGB': {}, + 'Rec.709': {}, + }, + 'look': { + 'sRGB to Rec.709': { + 'process_space': 'Rec.709', + }, + }, + 'role': { + 'reference': { + 'colorspace': 'sRGB', + }, + }, + } + result = get_labeled_colorspaces('config.ocio') + self.assertIsInstance(result, list) + self.assertTrue(all(isinstance(item, tuple) for item in result)) + + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_includes_colorspaces(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspace': { + 'sRGB': {} + }, + 'look': {}, + 'role': {}, + } + result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=False, include_roles=False) + self.assertEqual(result, [('sRGB', '[colorspace] sRGB')]) + + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_includes_aliases(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspace': { + 'sRGB': { + 'aliases': ['sRGB (D65)'], + }, + }, + 'look': {}, + 'role': {}, + } + result = get_labeled_colorspaces('config.ocio', include_aliases=True, include_looks=False, include_roles=False) + self.assertEqual(result, [('sRGB', '[colorspace] sRGB'), ('sRGB (D65)', '[alias] sRGB (D65) (sRGB)')]) + + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_includes_looks(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspace': {}, + 'look': { + 'sRGB to Rec.709': { + 'process_space': 'Rec.709', + }, + }, + 'role': {}, + } + result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=True, include_roles=False) + self.assertEqual(result, [('sRGB to Rec.709', '[look] sRGB to Rec.709 (Rec.709)')]) + + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_includes_roles(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspace': {}, + 'look': {}, + 'role': { + 'reference': { + 'colorspace': 'sRGB', + }, + }, + } + result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=False, include_roles=True) + self.assertEqual(result, [('reference', '[role] reference (sRGB)')]) From 7782c333dc6d4e0934b3f14dbb792730f9887eac Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 13:26:18 +0200 Subject: [PATCH 0381/1224] renaming test file --- ...space_labels.py => test_colorspace_get_labeled_colorspaces.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/openpype/pipeline/{test_colorspace_labels.py => test_colorspace_get_labeled_colorspaces.py} (100%) diff --git a/tests/unit/openpype/pipeline/test_colorspace_labels.py b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py similarity index 100% rename from tests/unit/openpype/pipeline/test_colorspace_labels.py rename to tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py From 13b46070fe9fef0b6474a50e9cebb7d7b73eac43 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 19:32:41 +0800 Subject: [PATCH 0382/1224] 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 b908665d4c4196acb9aae46f861eea55ffad35d9 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 4 Oct 2023 16:31:27 +0300 Subject: [PATCH 0383/1224] consider handleStart and End when collecting frames --- openpype/hosts/houdini/api/lib.py | 23 ++++++++--- .../publish/collect_instance_frame_data.py | 40 ++++--------------- .../plugins/publish/collect_instances.py | 14 +------ .../publish/collect_rop_frame_range.py | 16 +++++--- 4 files changed, 35 insertions(+), 58 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a3f691e1fc..ff6c62a143 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -548,7 +548,7 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node): +def get_frame_data(self, node, asset_data={}): """Get the frame data: start frame, end frame and steps. Args: @@ -561,16 +561,27 @@ def get_frame_data(node): data = {} if node.parm("trange") is None: - + self.log.debug( + "Node has no 'trange' parameter: {}".format(node.path()) + ) return data if node.evalParm("trange") == 0: - self.log.debug("trange is 0") + self.log.debug( + "Node '{}' has 'Render current frame' set. " + "Time range data ignored.".format(node.path()) + ) return data - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["steps"] = node.evalParm("f3") + data["frameStartHandle"] = node.evalParm("f1") + data["frameStart"] = node.evalParm("f1") + asset_data.get("handleStart", 0) + data["handleStart"] = asset_data.get("handleStart", 0) + + data["frameEndHandle"] = node.evalParm("f2") + data["frameEnd"] = node.evalParm("f2") - asset_data.get("handleEnd", 0) + data["handleEnd"] = asset_data.get("handleEnd", 0) + + data["byFrameStep"] = node.evalParm("f3") return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py index 584343cd64..97d18f97f0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py @@ -1,7 +1,7 @@ import hou import pyblish.api - +from openpype.hosts.houdini.api import lib class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): """Collect time range frame data for the instance node.""" @@ -15,42 +15,16 @@ class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): node_path = instance.data.get("instance_node") node = hou.node(node_path) if node_path else None if not node_path or not node: - self.log.debug("No instance node found for instance: " - "{}".format(instance)) + self.log.debug( + "No instance node found for instance: {}".format(instance) + ) return - frame_data = self.get_frame_data(node) + asset_data = instance.context.data["assetEntity"]["data"] + frame_data = lib.get_frame_data(self, node, asset_data) + if not frame_data: return self.log.info("Collected time data: {}".format(frame_data)) instance.data.update(frame_data) - - def get_frame_data(self, node): - """Get the frame data: start frame, end frame and steps - Args: - node(hou.Node) - - Returns: - dict - - """ - - data = {} - - if node.parm("trange") is None: - self.log.debug("Node has no 'trange' parameter: " - "{}".format(node.path())) - return data - - if node.evalParm("trange") == 0: - # Ignore 'render current frame' - self.log.debug("Node '{}' has 'Render current frame' set. " - "Time range data ignored.".format(node.path())) - return data - - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["byFrameStep"] = node.evalParm("f3") - - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 3772c9e705..132d297d1d 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -102,16 +102,4 @@ class CollectInstances(pyblish.api.ContextPlugin): """ - data = {} - - if node.parm("trange") is None: - return data - - if node.evalParm("trange") == 0: - return data - - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["byFrameStep"] = node.evalParm("f3") - - return data + return lib.get_frame_data(self, node) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 2a6be6b9f1..d91b9333b2 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -19,16 +19,20 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): return ropnode = hou.node(node_path) - frame_data = lib.get_frame_data(ropnode) + + asset_data = instance.context.data["assetEntity"]["data"] + frame_data = lib.get_frame_data(self, ropnode, asset_data) if "frameStart" in frame_data and "frameEnd" in frame_data: # Log artist friendly message about the collected frame range message = ( "Frame range {0[frameStart]} - {0[frameEnd]}" - ).format(frame_data) - if frame_data.get("step", 1.0) != 1.0: - message += " with step {0[step]}".format(frame_data) + .format(frame_data) + ) + if frame_data.get("byFrameStep", 1.0) != 1.0: + message += " with step {0[byFrameStep]}".format(frame_data) + self.log.info(message) instance.data.update(frame_data) @@ -36,6 +40,6 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): # Add frame range to label if the instance has a frame range. label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( - "{0} [{1[frameStart]} - {1[frameEnd]}]".format(label, - frame_data) + "{0} [{1[frameStart]} - {1[frameEnd]}]" + .format(label,frame_data) ) From 1629c76f2c4fa015b5ba472cc889fc4a9a6c10f2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 4 Oct 2023 16:42:01 +0300 Subject: [PATCH 0384/1224] consider handleStart and End in expected files --- openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_karma_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py | 4 ++-- .../hosts/houdini/plugins/publish/collect_redshift_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_vray_rop.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 43b8428c60..f1bd10345f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -126,8 +126,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index eabb1128d8..0a2fbfbac8 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -95,8 +95,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index c4460f5350..5a2e8fc24a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -118,8 +118,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index dbb15ab88f..8cfcd93dae 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -132,8 +132,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 277f922ba4..823a1a6593 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -115,8 +115,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) From 253c895363d2ec1fb0dcdc6d40d4539b120cefd6 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 21:48:44 +0800 Subject: [PATCH 0385/1224] 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 8ab9e6d6b2e27d254e6ca79f9efdd5c7eafa14e6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 4 Oct 2023 17:00:01 +0300 Subject: [PATCH 0386/1224] consider handleStart and End when submitting a deadline render job --- .../plugins/publish/submit_houdini_render_deadline.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 8f21a920be..2dbc17684b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -65,9 +65,12 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range + start = instance.data.get("frameStartHandle") or instance.data["frameStart"] + end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + frames = "{start}-{end}x{step}".format( - start=int(instance.data["frameStart"]), - end=int(instance.data["frameEnd"]), + start=int(start), + end=int(end), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames From 22ac8e7ac6fcece2aa73b066c02008b34bd6afff Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 4 Oct 2023 22:08:33 +0800 Subject: [PATCH 0387/1224] 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 0388/1224] 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 cc7f152404f0559520f00a1274fcf1003043213d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 4 Oct 2023 17:15:42 +0300 Subject: [PATCH 0389/1224] resolve hound --- openpype/hosts/houdini/api/lib.py | 5 ++++- .../hosts/houdini/plugins/publish/collect_arnold_rop.py | 8 ++++++-- .../hosts/houdini/plugins/publish/collect_karma_rop.py | 8 ++++++-- .../hosts/houdini/plugins/publish/collect_mantra_rop.py | 8 ++++++-- .../hosts/houdini/plugins/publish/collect_redshift_rop.py | 8 ++++++-- .../houdini/plugins/publish/collect_rop_frame_range.py | 2 +- .../hosts/houdini/plugins/publish/collect_vray_rop.py | 8 ++++++-- .../plugins/publish/submit_houdini_render_deadline.py | 7 +++++-- 8 files changed, 40 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ff6c62a143..964a866a79 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -548,7 +548,7 @@ def get_template_from_value(key, value): return parm -def get_frame_data(self, node, asset_data={}): +def get_frame_data(self, node, asset_data=None): """Get the frame data: start frame, end frame and steps. Args: @@ -558,6 +558,9 @@ def get_frame_data(self, node, asset_data={}): dict: frame data for star, end and steps. """ + if asset_data is None: + asset_data = {} + data = {} if node.parm("trange") is None: diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index f1bd10345f..edd71bfa39 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -126,8 +126,12 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index 0a2fbfbac8..564b58ebc2 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -95,8 +95,12 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 5a2e8fc24a..5ece889694 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -118,8 +118,12 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index 8cfcd93dae..1705da6a69 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -132,8 +132,12 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index d91b9333b2..bf44e019a9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -41,5 +41,5 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( "{0} [{1[frameStart]} - {1[frameEnd]}]" - .format(label,frame_data) + .format(label, frame_data) ) diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 823a1a6593..ec87e3eda3 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -115,8 +115,12 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 2dbc17684b..5dd16306c3 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -65,8 +65,11 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range - start = instance.data.get("frameStartHandle") or instance.data["frameStart"] - end = instance.data.get("frameEndHandle") or instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") or \ + instance.data["frameStart"] + + end = instance.data.get("frameEndHandle") or \ + instance.data["frameEnd"] frames = "{start}-{end}x{step}".format( start=int(start), From bf15868fc4cd0a75fe6017ddbdf316beb76af303 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 4 Oct 2023 17:48:16 +0200 Subject: [PATCH 0390/1224] 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 042d5d9d16546a6a0a57687a103870976f833141 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 19:10:26 +0200 Subject: [PATCH 0391/1224] colorspace types in plural also updating and fixing unit tests --- openpype/pipeline/colorspace.py | 23 ++++++++------ openpype/scripts/ocio_wrapper.py | 6 ++-- .../pipeline/publish/test_publish_plugins.py | 13 ++++---- ...test_colorspace_get_labeled_colorspaces.py | 30 +++++++++---------- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index c456baa70f..1088a15157 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -358,7 +358,7 @@ def parse_colorspace_from_filepath( colorspaces = ( colorspaces - or get_ocio_config_colorspaces(config_path)["colorspace"] + or get_ocio_config_colorspaces(config_path)["colorspaces"] ) underscored_colorspaces = { key.replace(" ", "_"): key for key in colorspaces @@ -396,7 +396,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): Returns: bool: True if exists """ - colorspaces = get_ocio_config_colorspaces(config_path)["colorspace"] + colorspaces = get_ocio_config_colorspaces(config_path)["colorspaces"] if colorspace_name not in colorspaces: raise KeyError( "Missing colorspace '{}' in config file '{}'".format( @@ -561,20 +561,25 @@ def get_labeled_colorspaces( looks = set() roles = set() for items_type, colorspace_items in config_items.items(): - if items_type == "colorspace": + if items_type == "colorspaces": for color_name, color_data in colorspace_items.items(): if color_data.get("aliases"): aliases.update([ - (alias_name, "[alias] {} ({})".format(alias_name, color_name)) - for alias_name in color_data["aliases"] - ]) + ( + alias_name, + "[alias] {} ({})".format(alias_name, color_name) + ) + for alias_name in color_data["aliases"] + ]) colorspaces.add(color_name) - elif items_type == "look": + + elif items_type == "looks": looks.update([ (name, "[look] {} ({})".format(name, role_data["process_space"])) for name, role_data in colorspace_items.items() ]) - elif items_type == "role": + + elif items_type == "roles": roles.update([ (name, "[role] {} ({})".format(name, role_data["colorspace"])) for name, role_data in colorspace_items.items() @@ -583,6 +588,7 @@ def get_labeled_colorspaces( if roles and include_roles: labeled_colorspaces.extend(roles) + # add colorspace after roles so it is first in menu labeled_colorspaces.extend(( (name, f"[colorspace] {name}") for name in colorspaces )) @@ -593,7 +599,6 @@ def get_labeled_colorspaces( if looks and include_looks: labeled_colorspaces.extend(looks) - return labeled_colorspaces diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index be21f0984f..bca977cc3b 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -107,7 +107,7 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) colorspace_data = { - "colorspace": { + "colorspaces": { color.getName(): { "family": color.getFamily(), "categories": list(color.getCategories()), @@ -121,7 +121,7 @@ def _get_colorspace_data(config_path): # add looks looks = config.getLooks() if looks: - colorspace_data["look"] = { + colorspace_data["looks"] = { look.getName(): {"process_space": look.getProcessSpace()} for look in looks } @@ -129,7 +129,7 @@ def _get_colorspace_data(config_path): # add roles roles = config.getRoles() if roles: - colorspace_data["role"] = { + colorspace_data["roles"] = { role: {"colorspace": colorspace} for (role, colorspace) in roles } diff --git a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py index aace8cf7e3..1f7f551237 100644 --- a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py +++ b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py @@ -37,7 +37,7 @@ class TestPipelinePublishPlugins(TestPipeline): # files are the same as those used in `test_pipeline_colorspace` TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) @@ -123,8 +123,7 @@ class TestPipelinePublishPlugins(TestPipeline): def test_get_colorspace_settings(self, context, config_path_asset): expected_config_template = ( - "{root[work]}/{project[name]}" - "/{hierarchy}/{asset}/config/aces.ocio" + "{root[work]}/{project[name]}/config/aces.ocio" ) expected_file_rules = { "comp_review": { @@ -177,16 +176,16 @@ class TestPipelinePublishPlugins(TestPipeline): # load plugin function for testing plugin = publish_plugins.ColormanagedPyblishPluginMixin() plugin.log = log + context.data["imageioSettings"] = (config_data_nuke, file_rules_nuke) plugin.set_representation_colorspace( - representation_nuke, context, - colorspace_settings=(config_data_nuke, file_rules_nuke) + representation_nuke, context ) # load plugin function for testing plugin = publish_plugins.ColormanagedPyblishPluginMixin() plugin.log = log + context.data["imageioSettings"] = (config_data_hiero, file_rules_hiero) plugin.set_representation_colorspace( - representation_hiero, context, - colorspace_settings=(config_data_hiero, file_rules_hiero) + representation_hiero, context ) colorspace_data_nuke = representation_nuke.get("colorspaceData") diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py index a135c3258b..ae3e4117bc 100644 --- a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py +++ b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py @@ -7,16 +7,16 @@ class TestGetLabeledColorspaces(unittest.TestCase): @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_returns_list_of_tuples(self, mock_get_ocio_config_colorspaces): mock_get_ocio_config_colorspaces.return_value = { - 'colorspace': { + 'colorspaces': { 'sRGB': {}, 'Rec.709': {}, }, - 'look': { + 'looks': { 'sRGB to Rec.709': { 'process_space': 'Rec.709', }, }, - 'role': { + 'roles': { 'reference': { 'colorspace': 'sRGB', }, @@ -29,11 +29,11 @@ class TestGetLabeledColorspaces(unittest.TestCase): @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_colorspaces(self, mock_get_ocio_config_colorspaces): mock_get_ocio_config_colorspaces.return_value = { - 'colorspace': { + 'colorspaces': { 'sRGB': {} }, - 'look': {}, - 'role': {}, + 'looks': {}, + 'roles': {}, } result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=False, include_roles=False) self.assertEqual(result, [('sRGB', '[colorspace] sRGB')]) @@ -41,13 +41,13 @@ class TestGetLabeledColorspaces(unittest.TestCase): @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_aliases(self, mock_get_ocio_config_colorspaces): mock_get_ocio_config_colorspaces.return_value = { - 'colorspace': { + 'colorspaces': { 'sRGB': { 'aliases': ['sRGB (D65)'], }, }, - 'look': {}, - 'role': {}, + 'looks': {}, + 'roles': {}, } result = get_labeled_colorspaces('config.ocio', include_aliases=True, include_looks=False, include_roles=False) self.assertEqual(result, [('sRGB', '[colorspace] sRGB'), ('sRGB (D65)', '[alias] sRGB (D65) (sRGB)')]) @@ -55,13 +55,13 @@ class TestGetLabeledColorspaces(unittest.TestCase): @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_looks(self, mock_get_ocio_config_colorspaces): mock_get_ocio_config_colorspaces.return_value = { - 'colorspace': {}, - 'look': { + 'colorspaces': {}, + 'looks': { 'sRGB to Rec.709': { 'process_space': 'Rec.709', }, }, - 'role': {}, + 'roles': {}, } result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=True, include_roles=False) self.assertEqual(result, [('sRGB to Rec.709', '[look] sRGB to Rec.709 (Rec.709)')]) @@ -69,9 +69,9 @@ class TestGetLabeledColorspaces(unittest.TestCase): @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_roles(self, mock_get_ocio_config_colorspaces): mock_get_ocio_config_colorspaces.return_value = { - 'colorspace': {}, - 'look': {}, - 'role': { + 'colorspaces': {}, + 'looks': {}, + 'roles': { 'reference': { 'colorspace': 'sRGB', }, From 957a713db216c82f9e30d01c8aecea7f2ac36663 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 4 Oct 2023 20:04:06 +0200 Subject: [PATCH 0392/1224] adding display and views --- openpype/pipeline/colorspace.py | 11 +++++++++++ openpype/scripts/ocio_wrapper.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 1088a15157..8985b07cde 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -538,6 +538,7 @@ def get_labeled_colorspaces( include_aliases=False, include_looks=False, include_roles=False, + include_display_views=False ): """Get all colorspace data with labels @@ -560,6 +561,7 @@ def get_labeled_colorspaces( colorspaces = set() looks = set() roles = set() + display_views = set() for items_type, colorspace_items in config_items.items(): if items_type == "colorspaces": for color_name, color_data in colorspace_items.items(): @@ -579,6 +581,12 @@ def get_labeled_colorspaces( for name, role_data in colorspace_items.items() ]) + elif items_type == "displays_views": + display_views.update([ + (name, "[view (display)] {}".format(name)) + for name, _ in colorspace_items.items() + ]) + elif items_type == "roles": roles.update([ (name, "[role] {} ({})".format(name, role_data["colorspace"])) @@ -599,6 +607,9 @@ def get_labeled_colorspaces( if looks and include_looks: labeled_colorspaces.extend(looks) + if display_views and include_display_views: + labeled_colorspaces.extend(display_views) + return labeled_colorspaces diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index bca977cc3b..fa231cd047 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -107,6 +107,7 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) colorspace_data = { + "roles": {}, "colorspaces": { color.getName(): { "family": color.getFamily(), @@ -115,7 +116,17 @@ def _get_colorspace_data(config_path): "equalitygroup": color.getEqualityGroup(), } for color in config.getColorSpaces() - } + }, + "displays_views": { + f"{view} ({display})": { + "display": display, + "view": view + + } + for display in config.getDisplays() + for view in config.getViews(display) + }, + "looks": {} } # add looks From 05b487a435f8add93e638c2bb4433d7c8dc15054 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 4 Oct 2023 20:30:45 +0200 Subject: [PATCH 0393/1224] 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 0c63b2691544723769f64ae110ba359bbdb0d73b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 4 Oct 2023 23:29:25 +0300 Subject: [PATCH 0394/1224] resolve some comments and add frame range validator --- openpype/hosts/houdini/api/lib.py | 13 ++-- .../plugins/publish/collect_arnold_rop.py | 7 +- .../publish/collect_instance_frame_data.py | 2 +- .../plugins/publish/collect_instances.py | 2 +- .../plugins/publish/collect_karma_rop.py | 7 +- .../plugins/publish/collect_mantra_rop.py | 7 +- .../plugins/publish/collect_redshift_rop.py | 7 +- .../publish/collect_rop_frame_range.py | 2 +- .../plugins/publish/collect_vray_rop.py | 7 +- .../plugins/publish/validate_frame_range.py | 75 +++++++++++++++++++ .../publish/submit_houdini_render_deadline.py | 8 +- 11 files changed, 98 insertions(+), 39 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_frame_range.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 964a866a79..711fae7cc4 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -548,7 +548,7 @@ def get_template_from_value(key, value): return parm -def get_frame_data(self, node, asset_data=None): +def get_frame_data(node, asset_data=None, log=None): """Get the frame data: start frame, end frame and steps. Args: @@ -561,28 +561,31 @@ def get_frame_data(self, node, asset_data=None): if asset_data is None: asset_data = {} + if log is None: + log = self.log + data = {} if node.parm("trange") is None: - self.log.debug( + log.debug( "Node has no 'trange' parameter: {}".format(node.path()) ) return data if node.evalParm("trange") == 0: - self.log.debug( + log.debug( "Node '{}' has 'Render current frame' set. " "Time range data ignored.".format(node.path()) ) return data data["frameStartHandle"] = node.evalParm("f1") - data["frameStart"] = node.evalParm("f1") + asset_data.get("handleStart", 0) data["handleStart"] = asset_data.get("handleStart", 0) + data["frameStart"] = data["frameStartHandle"] + data["handleStart"] data["frameEndHandle"] = node.evalParm("f2") - data["frameEnd"] = node.evalParm("f2") - asset_data.get("handleEnd", 0) data["handleEnd"] = asset_data.get("handleEnd", 0) + data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] data["byFrameStep"] = node.evalParm("f3") diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index edd71bfa39..9933572f4a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -126,11 +126,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py index 97d18f97f0..1426eadda1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py @@ -21,7 +21,7 @@ class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): return asset_data = instance.context.data["assetEntity"]["data"] - frame_data = lib.get_frame_data(self, node, asset_data) + frame_data = lib.get_frame_data(node, asset_data, self.log) if not frame_data: return diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 132d297d1d..b2e6107435 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -102,4 +102,4 @@ class CollectInstances(pyblish.api.ContextPlugin): """ - return lib.get_frame_data(self, node) + return lib.get_frame_data(node) diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index 564b58ebc2..32790dd550 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -95,11 +95,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 5ece889694..daaf87c04c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -118,11 +118,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index 1705da6a69..5ade67d181 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -132,11 +132,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index bf44e019a9..ccaa8b58e0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -21,7 +21,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): ropnode = hou.node(node_path) asset_data = instance.context.data["assetEntity"]["data"] - frame_data = lib.get_frame_data(self, ropnode, asset_data) + frame_data = lib.get_frame_data(ropnode, asset_data, self.log) if "frameStart" in frame_data and "frameEnd" in frame_data: diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index ec87e3eda3..e5c6ec20c4 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -115,11 +115,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py new file mode 100644 index 0000000000..e1be99dbcf --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import RepairAction +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class HotFixAction(RepairAction): + """Set End frame to the minimum valid value.""" + + label = "End frame hotfix" + + +class ValidateFrameRange(pyblish.api.InstancePlugin): + """Validate Frame Range. + + Due to the usage of start and end handles, + then Frame Range must be >= (start handle + end handle) + which results that frameEnd be smaller than frameStart + """ + + order = pyblish.api.ValidatorOrder - 0.1 + hosts = ["houdini"] + label = "Validate Frame Range" + actions = [HotFixAction, SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "Invalid Frame Range on: {0}".format(nodes), + title="Invalid Frame Range" + ) + + @classmethod + def get_invalid(cls, instance): + + if not instance.data.get("instance_node"): + return + + rop_node = hou.node(instance.data["instance_node"]) + if instance.data["frameStart"] > instance.data["frameEnd"]: + cls.log.error( + "Wrong frame range, please consider handle start and end.\n" + "frameEnd should at least be {}.\n" + "Use \"End frame hotfix\" action to do that." + .format( + instance.data["handleEnd"] + + instance.data["handleStart"] + + instance.data["frameStartHandle"] + ) + ) + return [rop_node] + + @classmethod + def repair(cls, instance): + rop_node = hou.node(instance.data["instance_node"]) + + frame_start = int(instance.data["frameStartHandle"]) + frame_end = int( + instance.data["frameStartHandle"] + + instance.data["handleStart"] + + instance.data["handleEnd"] + ) + + if rop_node.parm("f2").rawValue() == "$FEND": + hou.playbar.setFrameRange(frame_start, frame_end) + hou.playbar.setPlaybackRange(frame_start, frame_end) + hou.setFrame(frame_start) + else: + rop_node.parm("f2").set(frame_end) diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 5dd16306c3..cd71095920 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -65,12 +65,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range - start = instance.data.get("frameStartHandle") or \ - instance.data["frameStart"] - - end = instance.data.get("frameEndHandle") or \ - instance.data["frameEnd"] - + start = instance.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") frames = "{start}-{end}x{step}".format( start=int(start), end=int(end), From 664c27ced2688894ddef217f0fbe423119d68c4d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 15:21:12 +0800 Subject: [PATCH 0395/1224] 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 0396/1224] 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 0397/1224] 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 c8f6fb209bafc06dd12f536e64979e244af61c17 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 11:31:30 +0300 Subject: [PATCH 0398/1224] allow reseting handles, and remove the redundant collector --- openpype/hosts/houdini/api/lib.py | 8 +-- .../publish/collect_instance_frame_data.py | 30 -------- .../plugins/publish/collect_instances.py | 12 ---- .../publish/collect_rop_frame_range.py | 70 ++++++++++++++----- 4 files changed, 58 insertions(+), 62 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 711fae7cc4..3c39b32b0d 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -564,20 +564,20 @@ def get_frame_data(node, asset_data=None, log=None): if log is None: log = self.log - data = {} - if node.parm("trange") is None: log.debug( "Node has no 'trange' parameter: {}".format(node.path()) ) - return data + return if node.evalParm("trange") == 0: log.debug( "Node '{}' has 'Render current frame' set. " "Time range data ignored.".format(node.path()) ) - return data + return + + data = {} data["frameStartHandle"] = node.evalParm("f1") data["handleStart"] = asset_data.get("handleStart", 0) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py deleted file mode 100644 index 1426eadda1..0000000000 --- a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py +++ /dev/null @@ -1,30 +0,0 @@ -import hou - -import pyblish.api -from openpype.hosts.houdini.api import lib - -class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): - """Collect time range frame data for the instance node.""" - - order = pyblish.api.CollectorOrder + 0.001 - label = "Instance Node Frame Range" - hosts = ["houdini"] - - def process(self, instance): - - node_path = instance.data.get("instance_node") - node = hou.node(node_path) if node_path else None - if not node_path or not node: - self.log.debug( - "No instance node found for instance: {}".format(instance) - ) - return - - asset_data = instance.context.data["assetEntity"]["data"] - frame_data = lib.get_frame_data(node, asset_data, self.log) - - if not frame_data: - return - - self.log.info("Collected time data: {}".format(frame_data)) - instance.data.update(frame_data) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index b2e6107435..52966fb3c2 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -91,15 +91,3 @@ class CollectInstances(pyblish.api.ContextPlugin): context[:] = sorted(context, key=sort_by_family) return context - - def get_frame_data(self, node): - """Get the frame data: start frame, end frame and steps - Args: - node(hou.Node) - - Returns: - dict - - """ - - return lib.get_frame_data(node) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index ccaa8b58e0..75c101ed0f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -2,12 +2,17 @@ """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api +from openpype.lib import BoolDef from openpype.hosts.houdini.api import lib +from openpype.pipeline import OptionalPyblishPluginMixin -class CollectRopFrameRange(pyblish.api.InstancePlugin): +class CollectRopFrameRange(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Collect all frames which would be saved from the ROP nodes""" + hosts = ["houdini"] order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" @@ -16,30 +21,63 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): node_path = instance.data.get("instance_node") if node_path is None: # Instance without instance node like a workfile instance + self.log.debug( + "No instance node found for instance: {}".format(instance) + ) return ropnode = hou.node(node_path) - asset_data = instance.context.data["assetEntity"]["data"] + + attr_values = self.get_attr_values_from_data(instance.data) + if attr_values.get("reset_handles"): + asset_data["handleStart"] = 0 + asset_data["handleEnd"] = 0 + frame_data = lib.get_frame_data(ropnode, asset_data, self.log) - if "frameStart" in frame_data and "frameEnd" in frame_data: + if not frame_data: + return - # Log artist friendly message about the collected frame range - message = ( - "Frame range {0[frameStart]} - {0[frameEnd]}" + # Log artist friendly message about the collected frame range + message = "" + + if attr_values.get("reset_handles"): + message += ( + "Reset frame handles is activated for this instance, " + "start and end handles are set to 0.\n" + ) + else: + message += ( + "Full Frame range with Handles " + "{0[frameStartHandle]} - {0[frameEndHandle]}\n" .format(frame_data) ) - if frame_data.get("byFrameStep", 1.0) != 1.0: - message += " with step {0[byFrameStep]}".format(frame_data) - self.log.info(message) + message += ( + "Frame range {0[frameStart]} - {0[frameEnd]}" + .format(frame_data) + ) - instance.data.update(frame_data) + if frame_data.get("byFrameStep", 1.0) != 1.0: + message += "\nFrame steps {0[byFrameStep]}".format(frame_data) - # Add frame range to label if the instance has a frame range. - label = instance.data.get("label", instance.data["name"]) - instance.data["label"] = ( - "{0} [{1[frameStart]} - {1[frameEnd]}]" - .format(label, frame_data) - ) + self.log.info(message) + + instance.data.update(frame_data) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{0} [{1[frameStart]} - {1[frameEnd]}]" + .format(label, frame_data) + ) + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("reset_handles", + tooltip="Set frame handles to zero", + default=False, + label="Reset frame handles") + ] From db029884b0cc2d527dceac07ed0dd85663b1f48f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 5 Oct 2023 11:57:48 +0200 Subject: [PATCH 0399/1224] colorspace labeled unittests for display and view --- ...test_colorspace_get_labeled_colorspaces.py | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py index ae3e4117bc..1760000e45 100644 --- a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py +++ b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py @@ -35,7 +35,12 @@ class TestGetLabeledColorspaces(unittest.TestCase): 'looks': {}, 'roles': {}, } - result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=False, include_roles=False) + result = get_labeled_colorspaces( + 'config.ocio', + include_aliases=False, + include_looks=False, + include_roles=False + ) self.assertEqual(result, [('sRGB', '[colorspace] sRGB')]) @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') @@ -49,8 +54,18 @@ class TestGetLabeledColorspaces(unittest.TestCase): 'looks': {}, 'roles': {}, } - result = get_labeled_colorspaces('config.ocio', include_aliases=True, include_looks=False, include_roles=False) - self.assertEqual(result, [('sRGB', '[colorspace] sRGB'), ('sRGB (D65)', '[alias] sRGB (D65) (sRGB)')]) + result = get_labeled_colorspaces( + 'config.ocio', + include_aliases=True, + include_looks=False, + include_roles=False + ) + self.assertEqual( + result, [ + ('sRGB', '[colorspace] sRGB'), + ('sRGB (D65)', '[alias] sRGB (D65) (sRGB)') + ] + ) @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_looks(self, mock_get_ocio_config_colorspaces): @@ -63,8 +78,14 @@ class TestGetLabeledColorspaces(unittest.TestCase): }, 'roles': {}, } - result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=True, include_roles=False) - self.assertEqual(result, [('sRGB to Rec.709', '[look] sRGB to Rec.709 (Rec.709)')]) + result = get_labeled_colorspaces( + 'config.ocio', + include_aliases=False, + include_looks=True, + include_roles=False + ) + self.assertEqual( + result, [('sRGB to Rec.709', '[look] sRGB to Rec.709 (Rec.709)')]) @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') def test_includes_roles(self, mock_get_ocio_config_colorspaces): @@ -77,5 +98,29 @@ class TestGetLabeledColorspaces(unittest.TestCase): }, }, } - result = get_labeled_colorspaces('config.ocio', include_aliases=False, include_looks=False, include_roles=True) + result = get_labeled_colorspaces( + 'config.ocio', + include_aliases=False, + include_looks=False, + include_roles=True + ) self.assertEqual(result, [('reference', '[role] reference (sRGB)')]) + + @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') + def test_includes_display_views(self, mock_get_ocio_config_colorspaces): + mock_get_ocio_config_colorspaces.return_value = { + 'colorspaces': {}, + 'looks': {}, + 'roles': {}, + 'displays_views': { + 'sRGB (ACES)': { + 'view': 'sRGB', + 'display': 'ACES', + }, + }, + } + result = get_labeled_colorspaces( + 'config.ocio', + include_display_views=True + ) + self.assertEqual(result, [('sRGB (ACES)', '[view (display)] sRGB (ACES)')]) From 0c0b52d850341681dfde08acc94d36fc660f1617 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 5 Oct 2023 11:43:28 +0100 Subject: [PATCH 0400/1224] Dont update node name on update --- openpype/hosts/nuke/plugins/load/load_image.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 0dd3a940db..6bffb97e6f 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -204,8 +204,6 @@ class LoadImage(load.LoaderPlugin): last = first = int(frame_number) # Set the global in to the start frame of the sequence - read_name = self._get_node_name(representation) - node["name"].setValue(read_name) node["file"].setValue(file) node["origfirst"].setValue(first) node["first"].setValue(first) From 839269562347abde3132439fec3c2a78e9a23a44 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 5 Oct 2023 11:49:19 +0100 Subject: [PATCH 0401/1224] Improved update for Geometry Caches --- openpype/hosts/unreal/api/pipeline.py | 82 +++++---- .../plugins/load/load_geometrycache_abc.py | 162 +++++++++++------- 2 files changed, 153 insertions(+), 91 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 39638ac40f..2893550325 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -649,30 +649,43 @@ def generate_sequence(h, h_dir): return sequence, (min_frame, max_frame) -def replace_static_mesh_actors(old_assets, new_assets): +def _get_comps_and_assets( + component_class, asset_class, old_assets, new_assets +): eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) - smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) - comps = eas.get_all_level_actors_components() - static_mesh_comps = [ - c for c in comps if isinstance(c, unreal.StaticMeshComponent) + components = [ + c for c in comps if isinstance(c, component_class) ] # Get all the static meshes among the old assets in a dictionary with # the name as key - old_meshes = {} + selected_old_assets = {} for a in old_assets: asset = unreal.EditorAssetLibrary.load_asset(a) - if isinstance(asset, unreal.StaticMesh): - old_meshes[asset.get_name()] = asset + if isinstance(asset, asset_class): + selected_old_assets[asset.get_name()] = asset # Get all the static meshes among the new assets in a dictionary with # the name as key - new_meshes = {} + selected_new_assets = {} for a in new_assets: asset = unreal.EditorAssetLibrary.load_asset(a) - if isinstance(asset, unreal.StaticMesh): - new_meshes[asset.get_name()] = asset + if isinstance(asset, asset_class): + selected_new_assets[asset.get_name()] = asset + + return components, selected_old_assets, selected_new_assets + + +def replace_static_mesh_actors(old_assets, new_assets): + smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) + + static_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( + unreal.StaticMeshComponent, + unreal.StaticMesh, + old_assets, + new_assets + ) for old_name, old_mesh in old_meshes.items(): new_mesh = new_meshes.get(old_name) @@ -685,28 +698,12 @@ def replace_static_mesh_actors(old_assets, new_assets): def replace_skeletal_mesh_actors(old_assets, new_assets): - eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) - - comps = eas.get_all_level_actors_components() - skeletal_mesh_comps = [ - c for c in comps if isinstance(c, unreal.SkeletalMeshComponent) - ] - - # Get all the static meshes among the old assets in a dictionary with - # the name as key - old_meshes = {} - for a in old_assets: - asset = unreal.EditorAssetLibrary.load_asset(a) - if isinstance(asset, unreal.SkeletalMesh): - old_meshes[asset.get_name()] = asset - - # Get all the static meshes among the new assets in a dictionary with - # the name as key - new_meshes = {} - for a in new_assets: - asset = unreal.EditorAssetLibrary.load_asset(a) - if isinstance(asset, unreal.SkeletalMesh): - new_meshes[asset.get_name()] = asset + skeletal_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( + unreal.SkeletalMeshComponent, + unreal.SkeletalMesh, + old_assets, + new_assets + ) for old_name, old_mesh in old_meshes.items(): new_mesh = new_meshes.get(old_name) @@ -719,6 +716,25 @@ def replace_skeletal_mesh_actors(old_assets, new_assets): comp.set_skeletal_mesh_asset(new_mesh) +def replace_geometry_cache_actors(old_assets, new_assets): + geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets( + unreal.SkeletalMeshComponent, + unreal.SkeletalMesh, + old_assets, + new_assets + ) + + for old_name, old_mesh in old_caches.items(): + new_mesh = new_caches.get(old_name) + + if not new_mesh: + continue + + for comp in geometry_cache_comps: + if comp.get_editor_property("geometry_cache") == old_mesh: + comp.set_geometry_cache(new_mesh) + + def delete_previous_asset_if_unused(container, asset_content): ar = unreal.AssetRegistryHelpers.get_asset_registry() diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index 13ba236a7d..879574f75b 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -7,7 +7,12 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + create_container, + imprint, + replace_geometry_cache_actors, + delete_previous_asset_if_unused, +) import unreal # noqa @@ -21,8 +26,11 @@ class PointCacheAlembicLoader(plugin.Loader): icon = "cube" color = "orange" + root = "/Game/Ayon/Assets" + + @staticmethod def get_task( - self, filename, asset_dir, asset_name, replace, + filename, asset_dir, asset_name, replace, frame_start=None, frame_end=None ): task = unreal.AssetImportTask() @@ -38,8 +46,6 @@ class PointCacheAlembicLoader(plugin.Loader): task.set_editor_property('automated', True) task.set_editor_property('save', True) - # set import options here - # Unreal 4.24 ignores the settings. It works with Unreal 4.26 options.set_editor_property( 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE) @@ -64,13 +70,42 @@ class PointCacheAlembicLoader(plugin.Loader): return task - def load(self, context, name, namespace, data): - """Load and containerise representation into Content Browser. + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + frame_start, frame_end + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + task = self.get_task( + filepath, asset_dir, asset_name, False, frame_start, frame_end) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + # Create Asset Container + create_container(container=container_name, path=asset_dir) + + def imprint( + self, asset, asset_dir, container_name, asset_name, representation, + frame_start, frame_end + ): + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"], + "frame_start": frame_start, + "frame_end": frame_end + } + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. Args: context (dict): application context @@ -79,30 +114,28 @@ class PointCacheAlembicLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + data (dict): Those would be data to be imprinted. Returns: list(str): list of container content - """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" else: - asset_name = "{}".format(name) + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) - frame_start = context.get('asset').get('data').get('frameStart') frame_end = context.get('asset').get('data').get('frameEnd') @@ -111,30 +144,17 @@ class PointCacheAlembicLoader(plugin.Loader): if frame_start == frame_end: frame_end += 1 - path = self.filepath_from_context(context) - task = self.get_task( - path, asset_dir, asset_name, False, frame_start, frame_end) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + self.import_and_containerize( + path, asset_dir, asset_name, container_name, + frame_start, frame_end) - data = { - "schema": "ayon:container-2.0", - "id": AYON_CONTAINER_ID, - "asset": asset, - "namespace": asset_dir, - "container_name": container_name, - "asset_name": asset_name, - "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] - } - unreal_pipeline.imprint( - "{}/{}".format(asset_dir, container_name), data) + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"], frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -146,32 +166,58 @@ class PointCacheAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] - representation["context"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, False) - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + unreal.log_warning(context) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + if not context: + raise RuntimeError("No context found in representation") + + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + frame_start = int(container.get("frame_start")) + frame_end = int(container.get("frame_end")) + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name, + frame_start, frame_end) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation, + frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) + old_assets = unreal.EditorAssetLibrary.list_assets( + container["namespace"], recursive=True, include_folder=False + ) + + replace_geometry_cache_actors(old_assets, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(container, old_assets) + def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) From dad73b4adb98da4ac91e8f690e65b96051f38b50 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 5 Oct 2023 11:51:37 +0100 Subject: [PATCH 0402/1224] Hound fixes --- openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index 879574f75b..8ac2656bd7 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -147,7 +147,6 @@ class PointCacheAlembicLoader(plugin.Loader): if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) - self.import_and_containerize( path, asset_dir, asset_name, container_name, frame_start, frame_end) From d26df62e1502beed52522efe3a4b5a6bb9679ee8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 5 Oct 2023 13:00:52 +0200 Subject: [PATCH 0403/1224] 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 0404/1224] 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 fa11a2bfdcddb6b085641f1c3c078ee34c5405aa Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 19:17:25 +0800 Subject: [PATCH 0405/1224] small tweaks on loader and extractor & use raise PublishValidationError for each instead of using reports append list of error. --- openpype/hosts/max/plugins/load/load_tycache.py | 3 +-- .../hosts/max/plugins/publish/extract_tycache.py | 13 ++++++------- .../max/plugins/publish/validate_tyflow_data.py | 16 ++++++++-------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index 657e743087..7eac0de3e5 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -49,8 +49,7 @@ class PointCloudLoader(load.LoaderPlugin): update_custom_attribute_data( node, node_list) with maintained_selection(): - rt.Select(node_list) - for prt in rt.Selection: + for prt in node_list: prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 56fd39406e..0327564b3a 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -42,18 +42,17 @@ class ExtractTyCache(publish.Extractor): with maintained_selection(): job_args = None + has_tyc_spline = ( + True + if instance.data["tycache_type"] == "tycachespline" + else False + ) if instance.data["tycache_type"] == "tycache": - job_args = self.export_particle( - instance.data["members"], - start, end, path, - additional_attributes) - elif instance.data["tycache_type"] == "tycachespline": job_args = self.export_particle( instance.data["members"], start, end, path, additional_attributes, - tycache_spline_enabled=True) - + tycache_spline_enabled=has_tyc_spline) for job in job_args: rt.Execute(job) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index c0a6d23022..59dafef901 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -23,15 +23,14 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): invalid_object = self.get_tyflow_object(instance) if invalid_object: - report.append(f"Non tyFlow object found: {invalid_object}") + raise PublishValidationError( + f"Non tyFlow object found: {invalid_object}") invalid_operator = self.get_tyflow_operator(instance) if invalid_operator: - report.append(( + raise PublishValidationError(( "tyFlow ExportParticle operator not " f"found: {invalid_operator}")) - if report: - raise PublishValidationError(f"{report}") def get_tyflow_object(self, instance): """Get the nodes which are not tyFlow object(s) @@ -46,7 +45,7 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): """ invalid = [] container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow container for {container}") + self.log.debug(f"Validating tyFlow container for {container}") selection_list = instance.data["members"] for sel in selection_list: @@ -61,7 +60,8 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): return invalid def get_tyflow_operator(self, instance): - """_summary_ + """Check if the Export Particle Operators in the node + connections. Args: instance (str): instance node @@ -73,7 +73,7 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): """ invalid = [] container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow object for {container}") + self.log.debug(f"Validating tyFlow object for {container}") selection_list = instance.data["members"] bool_list = [] for sel in selection_list: @@ -88,7 +88,7 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): # if the export_particles property is not there # it means there is not a "Export Particle" operator if "True" not in bool_list: - self.log.error("Operator 'Export Particles' not found!") + self.log.error("Operator 'Export Particles' not found.") invalid.append(sel) return invalid From 291fd65b0969f07ce2767971ba5a095233c682fa Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 19:20:14 +0800 Subject: [PATCH 0406/1224] hound --- openpype/hosts/max/plugins/publish/validate_tyflow_data.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index 59dafef901..dc2de55e4f 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -19,12 +19,10 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): 2. Validate if tyFlow operator Export Particle exists """ - report = [] - invalid_object = self.get_tyflow_object(instance) if invalid_object: - raise PublishValidationError( - f"Non tyFlow object found: {invalid_object}") + raise PublishValidationError( + f"Non tyFlow object found: {invalid_object}") invalid_operator = self.get_tyflow_operator(instance) if invalid_operator: From 6e56ba2cc17a384dff0f27ed96e00dd2d7e54e22 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 14:49:34 +0300 Subject: [PATCH 0407/1224] Bigroy's comments and update attribute def label and tip --- openpype/hosts/houdini/api/lib.py | 8 +++---- .../publish/collect_rop_frame_range.py | 24 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 3c39b32b0d..711fae7cc4 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -564,20 +564,20 @@ def get_frame_data(node, asset_data=None, log=None): if log is None: log = self.log + data = {} + if node.parm("trange") is None: log.debug( "Node has no 'trange' parameter: {}".format(node.path()) ) - return + return data if node.evalParm("trange") == 0: log.debug( "Node '{}' has 'Render current frame' set. " "Time range data ignored.".format(node.path()) ) - return - - data = {} + return data data["frameStartHandle"] = node.evalParm("f1") data["handleStart"] = asset_data.get("handleStart", 0) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 75c101ed0f..1f65d4eea6 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -30,7 +30,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, asset_data = instance.context.data["assetEntity"]["data"] attr_values = self.get_attr_values_from_data(instance.data) - if attr_values.get("reset_handles"): + if not attr_values.get("use_handles"): asset_data["handleStart"] = 0 asset_data["handleEnd"] = 0 @@ -42,17 +42,17 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, # Log artist friendly message about the collected frame range message = "" - if attr_values.get("reset_handles"): - message += ( - "Reset frame handles is activated for this instance, " - "start and end handles are set to 0.\n" - ) - else: + if attr_values.get("use_handles"): message += ( "Full Frame range with Handles " "{0[frameStartHandle]} - {0[frameEndHandle]}\n" .format(frame_data) ) + else: + message += ( + "Use handles is deactivated for this instance, " + "start and end handles are set to 0.\n" + ) message += ( "Frame range {0[frameStart]} - {0[frameEnd]}" @@ -76,8 +76,10 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): return [ - BoolDef("reset_handles", - tooltip="Set frame handles to zero", - default=False, - label="Reset frame handles") + BoolDef("use_handles", + tooltip="Disable this if you don't want the publisher" + " to ignore start and end handles specified in the asset data" + " for this publish instance", + default=True, + label="Use asset handles") ] From ec2ee09fe974f8500765ec65571996a93945951a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 14:51:33 +0300 Subject: [PATCH 0408/1224] resolve hound --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 1f65d4eea6..37db922201 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -78,8 +78,8 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return [ BoolDef("use_handles", tooltip="Disable this if you don't want the publisher" - " to ignore start and end handles specified in the asset data" - " for this publish instance", + " to ignore start and end handles specified in the" + " asset data for this publish instance", default=True, label="Use asset handles") ] From ad252347c0bf59db86ee46a19525d254837c092b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 20:27:22 +0800 Subject: [PATCH 0409/1224] improve the validation report --- .../max/plugins/publish/validate_tyflow_data.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index dc2de55e4f..bcdc9c6dfe 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -19,16 +19,20 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): 2. Validate if tyFlow operator Export Particle exists """ + report = [] + invalid_object = self.get_tyflow_object(instance) - if invalid_object: - raise PublishValidationError( - f"Non tyFlow object found: {invalid_object}") + self.log.error(f"Non tyFlow object found: {invalid_object}") invalid_operator = self.get_tyflow_operator(instance) if invalid_operator: - raise PublishValidationError(( - "tyFlow ExportParticle operator not " - f"found: {invalid_operator}")) + self.log.error( + "Operator 'Export Particles' not found in tyFlow editor.") + if report: + raise PublishValidationError( + "issues occurred", + description="Container should only include tyFlow object\n " + "and tyflow operator Export Particle should be in the tyFlow editor") def get_tyflow_object(self, instance): """Get the nodes which are not tyFlow object(s) @@ -86,7 +90,6 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): # if the export_particles property is not there # it means there is not a "Export Particle" operator if "True" not in bool_list: - self.log.error("Operator 'Export Particles' not found.") invalid.append(sel) return invalid From e364f4e74c86e3e5e6779617ca6fb5a835b18e3b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 5 Oct 2023 20:41:44 +0800 Subject: [PATCH 0410/1224] hound --- openpype/hosts/max/plugins/publish/validate_tyflow_data.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index bcdc9c6dfe..a359100e6e 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -19,20 +19,21 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): 2. Validate if tyFlow operator Export Particle exists """ - report = [] invalid_object = self.get_tyflow_object(instance) + if invalid_object: self.log.error(f"Non tyFlow object found: {invalid_object}") invalid_operator = self.get_tyflow_operator(instance) if invalid_operator: self.log.error( "Operator 'Export Particles' not found in tyFlow editor.") - if report: + if invalid_object or invalid_operator: raise PublishValidationError( "issues occurred", description="Container should only include tyFlow object\n " - "and tyflow operator Export Particle should be in the tyFlow editor") + "and tyflow operator 'Export Particle' should be in \n" + "the tyFlow editor") def get_tyflow_object(self, instance): """Get the nodes which are not tyFlow object(s) 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 0411/1224] 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 0412/1224] 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 0413/1224] 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 0414/1224] 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 0415/1224] 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 0416/1224] 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 0417/1224] 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 0418/1224] 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 0419/1224] 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 0420/1224] 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 0421/1224] 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 0422/1224] 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 0423/1224] 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 9d6340a8a18a0e41651580a38fedbb9dec732f5c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 5 Oct 2023 17:03:54 +0200 Subject: [PATCH 0424/1224] colorspace: add`convert_colorspace_enumerator_item` - improving unittests - adding unittest for `convert_colorspace_enumerator_item` - separating `config_items` from `get_colorspaces_enumerator_items` so they can be stored in context --- .../plugins/create/create_colorspace_look.py | 7 +- .../publish/collect_explicit_colorspace.py | 7 +- openpype/pipeline/colorspace.py | 94 ++++++++++--- ...pace_convert_colorspace_enumerator_item.py | 118 ++++++++++++++++ ...rspace_get_colorspaces_enumerator_items.py | 114 ++++++++++++++++ ...test_colorspace_get_labeled_colorspaces.py | 126 ------------------ 6 files changed, 321 insertions(+), 145 deletions(-) create mode 100644 tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py create mode 100644 tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py delete mode 100644 tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index 3f3fa5348a..3e1c20d96a 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -36,6 +36,7 @@ class CreateColorspaceLook(TrayPublishCreator): (None, "Not set") ] colorspace_attr_show = False + config_items = None def get_detail_description(self): return """# Colorspace Look @@ -148,11 +149,13 @@ This creator publishes color space look file (LUT). if config_data: filepath = config_data["path"] - labeled_colorspaces = colorspace.get_labeled_colorspaces( - filepath, + config_items = colorspace.get_ocio_config_colorspaces(filepath) + labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( + config_items, include_aliases=True, include_roles=True ) + self.config_items = config_items self.colorspace_items.extend(labeled_colorspaces) self.enabled = True diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index 06ceac5923..5db2b0cbad 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -22,6 +22,7 @@ class CollectColorspace(pyblish.api.InstancePlugin, (None, "Don't override") ] colorspace_attr_show = False + config_items = None def process(self, instance): values = self.get_attr_values_from_data(instance.data) @@ -51,11 +52,13 @@ class CollectColorspace(pyblish.api.InstancePlugin, if config_data: filepath = config_data["path"] - labeled_colorspaces = colorspace.get_labeled_colorspaces( - filepath, + config_items = colorspace.get_ocio_config_colorspaces(filepath) + labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( + config_items, include_aliases=True, include_roles=True ) + cls.config_items = config_items cls.colorspace_items.extend(labeled_colorspaces) cls.enabled = True diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 8985b07cde..8bebc934fc 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -1,4 +1,3 @@ -from copy import deepcopy import re import os import json @@ -7,6 +6,7 @@ import functools import platform import tempfile import warnings +from copy import deepcopy from openpype import PACKAGE_DIR from openpype.settings import get_project_settings @@ -533,13 +533,63 @@ def get_ocio_config_colorspaces(config_path): return CachedData.ocio_config_colorspaces[config_path] -def get_labeled_colorspaces( - config_path, +def convert_colorspace_enumerator_item( + colorspace_enum_item, + config_items +): + """Convert colorspace enumerator item to dictionary + + Args: + colorspace_item (str): colorspace and family in couple + config_items (dict[str,dict]): colorspace data + + Returns: + dict: colorspace data + """ + # split string with `::` separator and set first as key and second as value + item_type, item_name = colorspace_enum_item.split("::") + + item_data = None + if item_type == "aliases": + # loop through all colorspaces and find matching alias + for name, _data in config_items.get("colorspaces", {}).items(): + if item_name in _data.get("aliases", []): + item_data = deepcopy(_data) + item_data.update({ + "name": name, + "type": "colorspace" + }) + break + else: + # find matching colorspace item found in labeled_colorspaces + item_data = config_items.get(item_type, {}).get(item_name) + if item_data: + item_data = deepcopy(item_data) + item_data.update({ + "name": item_name, + "type": item_type + }) + + # raise exception if item is not found + if not item_data: + message_config_keys = ", ".join( + "'{}':{}".format(key, set(config_items.get(key, {}).keys())) for key in config_items.keys() + ) + raise KeyError( + "Missing colorspace item '{}' in config data: [{}]".format( + colorspace_enum_item, message_config_keys + ) + ) + + return item_data + + +def get_colorspaces_enumerator_items( + config_items, include_aliases=False, include_looks=False, include_roles=False, include_display_views=False - ): """Get all colorspace data with labels @@ -547,7 +597,7 @@ def get_labeled_colorspaces( Families can be used for building menu and submenus in gui. Args: - config_path (str): path leading to config.ocio file + config_items (dict[str,dict]): colorspace data include_aliases (bool): include aliases in result include_looks (bool): include looks in result include_roles (bool): include roles in result @@ -555,7 +605,6 @@ def get_labeled_colorspaces( Returns: list[tuple[str,str]]: colorspace and family in couple """ - config_items = get_ocio_config_colorspaces(config_path) labeled_colorspaces = [] aliases = set() colorspaces = set() @@ -568,46 +617,61 @@ def get_labeled_colorspaces( if color_data.get("aliases"): aliases.update([ ( - alias_name, + "aliases::{}".format(alias_name), "[alias] {} ({})".format(alias_name, color_name) ) for alias_name in color_data["aliases"] ]) - colorspaces.add(color_name) + colorspaces.add(( + "{}::{}".format(items_type, color_name), + "[colorspace] {}".format(color_name) + )) elif items_type == "looks": looks.update([ - (name, "[look] {} ({})".format(name, role_data["process_space"])) + ( + "{}::{}".format(items_type, name), + "[look] {} ({})".format(name, role_data["process_space"]) + ) for name, role_data in colorspace_items.items() ]) elif items_type == "displays_views": display_views.update([ - (name, "[view (display)] {}".format(name)) + ( + "{}::{}".format(items_type, name), + "[view (display)] {}".format(name) + ) for name, _ in colorspace_items.items() ]) elif items_type == "roles": roles.update([ - (name, "[role] {} ({})".format(name, role_data["colorspace"])) + ( + "{}::{}".format(items_type, name), + "[role] {} ({})".format(name, role_data["colorspace"]) + ) for name, role_data in colorspace_items.items() ]) if roles and include_roles: + roles = sorted(roles, key=lambda x: x[0]) labeled_colorspaces.extend(roles) - # add colorspace after roles so it is first in menu - labeled_colorspaces.extend(( - (name, f"[colorspace] {name}") for name in colorspaces - )) + # add colorspaces as second so it is not first in menu + colorspaces = sorted(colorspaces, key=lambda x: x[0]) + labeled_colorspaces.extend(colorspaces) if aliases and include_aliases: + aliases = sorted(aliases, key=lambda x: x[0]) labeled_colorspaces.extend(aliases) if looks and include_looks: + looks = sorted(looks, key=lambda x: x[0]) labeled_colorspaces.extend(looks) if display_views and include_display_views: + display_views = sorted(display_views, key=lambda x: x[0]) labeled_colorspaces.extend(display_views) return labeled_colorspaces diff --git a/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py new file mode 100644 index 0000000000..bffe8eda90 --- /dev/null +++ b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py @@ -0,0 +1,118 @@ +from ast import alias +import unittest +from openpype.pipeline.colorspace import convert_colorspace_enumerator_item + + +class TestConvertColorspaceEnumeratorItem(unittest.TestCase): + def setUp(self): + self.config_items = { + "colorspaces": { + "sRGB": { + "aliases": ["sRGB_1"], + "family": "colorspace", + "categories": ["colors"], + "equalitygroup": "equalitygroup", + }, + "Rec.709": { + "aliases": ["rec709_1", "rec709_2"], + }, + }, + "looks": { + "sRGB_to_Rec.709": { + "process_space": "sRGB", + }, + }, + "displays_views": { + "sRGB (ACES)": { + "view": "sRGB", + "display": "ACES", + }, + "Rec.709 (ACES)": { + "view": "Rec.709", + "display": "ACES", + }, + }, + "roles": { + "compositing_linear": { + "colorspace": "linear", + }, + }, + } + + def test_valid_item(self): + colorspace_item_data = convert_colorspace_enumerator_item( + "colorspaces::sRGB", self.config_items) + self.assertEqual( + colorspace_item_data, + { + "name": "sRGB", + "type": "colorspaces", + "aliases": ["sRGB_1"], + "family": "colorspace", + "categories": ["colors"], + "equalitygroup": "equalitygroup" + } + ) + + alias_item_data = convert_colorspace_enumerator_item( + "aliases::rec709_1", self.config_items) + self.assertEqual( + alias_item_data, + { + "aliases": ["rec709_1", "rec709_2"], + "name": "Rec.709", + "type": "colorspace" + } + ) + + display_view_item_data = convert_colorspace_enumerator_item( + "displays_views::sRGB (ACES)", self.config_items) + self.assertEqual( + display_view_item_data, + { + "type": "displays_views", + "name": "sRGB (ACES)", + "view": "sRGB", + "display": "ACES" + } + ) + + role_item_data = convert_colorspace_enumerator_item( + "roles::compositing_linear", self.config_items) + self.assertEqual( + role_item_data, + { + "name": "compositing_linear", + "type": "roles", + "colorspace": "linear" + } + ) + + look_item_data = convert_colorspace_enumerator_item( + "looks::sRGB_to_Rec.709", self.config_items) + self.assertEqual( + look_item_data, + { + "type": "looks", + "name": "sRGB_to_Rec.709", + "process_space": "sRGB" + } + ) + + def test_invalid_item(self): + config_items = { + "RGB": { + "sRGB": {"red": 255, "green": 255, "blue": 255}, + "AdobeRGB": {"red": 255, "green": 255, "blue": 255}, + } + } + with self.assertRaises(KeyError): + convert_colorspace_enumerator_item("RGB::invalid", config_items) + + def test_missing_config_data(self): + config_items = {} + with self.assertRaises(KeyError): + convert_colorspace_enumerator_item("RGB::sRGB", config_items) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py new file mode 100644 index 0000000000..de3e333670 --- /dev/null +++ b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py @@ -0,0 +1,114 @@ +import unittest + +from openpype.pipeline.colorspace import get_colorspaces_enumerator_items + + +class TestGetColorspacesEnumeratorItems(unittest.TestCase): + def setUp(self): + self.config_items = { + "colorspaces": { + "sRGB": { + "aliases": ["sRGB_1"], + }, + "Rec.709": { + "aliases": ["rec709_1", "rec709_2"], + }, + }, + "looks": { + "sRGB_to_Rec.709": { + "process_space": "sRGB", + }, + }, + "displays_views": { + "sRGB (ACES)": { + "view": "sRGB", + "display": "ACES", + }, + "Rec.709 (ACES)": { + "view": "Rec.709", + "display": "ACES", + }, + }, + "roles": { + "compositing_linear": { + "colorspace": "linear", + }, + }, + } + + def test_colorspaces(self): + result = get_colorspaces_enumerator_items(self.config_items) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ] + self.assertEqual(result, expected) + + def test_aliases(self): + result = get_colorspaces_enumerator_items(self.config_items, include_aliases=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"), + ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"), + ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"), + ] + self.assertEqual(result, expected) + + def test_looks(self): + result = get_colorspaces_enumerator_items(self.config_items, include_looks=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"), + ] + self.assertEqual(result, expected) + + def test_display_views(self): + result = get_colorspaces_enumerator_items(self.config_items, include_display_views=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), + ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), + + ] + self.assertEqual(result, expected) + + def test_roles(self): + result = get_colorspaces_enumerator_items(self.config_items, include_roles=True) + expected = [ + ("roles::compositing_linear", "[role] compositing_linear (linear)"), + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ] + self.assertEqual(result, expected) + + def test_all(self): + message_config_keys = ", ".join( + "'{}':{}".format(key, set(self.config_items.get(key, {}).keys())) for key in self.config_items.keys() + ) + print("Testing with config: [{}]".format(message_config_keys)) + result = get_colorspaces_enumerator_items( + self.config_items, + include_aliases=True, + include_looks=True, + include_roles=True, + include_display_views=True, + ) + expected = [ + ("roles::compositing_linear", "[role] compositing_linear (linear)"), + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"), + ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"), + ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"), + ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), + ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), + ] + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py b/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py deleted file mode 100644 index 1760000e45..0000000000 --- a/tests/unit/openpype/pipeline/test_colorspace_get_labeled_colorspaces.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest -from unittest.mock import patch -from openpype.pipeline.colorspace import get_labeled_colorspaces - - -class TestGetLabeledColorspaces(unittest.TestCase): - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_returns_list_of_tuples(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': { - 'sRGB': {}, - 'Rec.709': {}, - }, - 'looks': { - 'sRGB to Rec.709': { - 'process_space': 'Rec.709', - }, - }, - 'roles': { - 'reference': { - 'colorspace': 'sRGB', - }, - }, - } - result = get_labeled_colorspaces('config.ocio') - self.assertIsInstance(result, list) - self.assertTrue(all(isinstance(item, tuple) for item in result)) - - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_includes_colorspaces(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': { - 'sRGB': {} - }, - 'looks': {}, - 'roles': {}, - } - result = get_labeled_colorspaces( - 'config.ocio', - include_aliases=False, - include_looks=False, - include_roles=False - ) - self.assertEqual(result, [('sRGB', '[colorspace] sRGB')]) - - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_includes_aliases(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': { - 'sRGB': { - 'aliases': ['sRGB (D65)'], - }, - }, - 'looks': {}, - 'roles': {}, - } - result = get_labeled_colorspaces( - 'config.ocio', - include_aliases=True, - include_looks=False, - include_roles=False - ) - self.assertEqual( - result, [ - ('sRGB', '[colorspace] sRGB'), - ('sRGB (D65)', '[alias] sRGB (D65) (sRGB)') - ] - ) - - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_includes_looks(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': {}, - 'looks': { - 'sRGB to Rec.709': { - 'process_space': 'Rec.709', - }, - }, - 'roles': {}, - } - result = get_labeled_colorspaces( - 'config.ocio', - include_aliases=False, - include_looks=True, - include_roles=False - ) - self.assertEqual( - result, [('sRGB to Rec.709', '[look] sRGB to Rec.709 (Rec.709)')]) - - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_includes_roles(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': {}, - 'looks': {}, - 'roles': { - 'reference': { - 'colorspace': 'sRGB', - }, - }, - } - result = get_labeled_colorspaces( - 'config.ocio', - include_aliases=False, - include_looks=False, - include_roles=True - ) - self.assertEqual(result, [('reference', '[role] reference (sRGB)')]) - - @patch('openpype.pipeline.colorspace.get_ocio_config_colorspaces') - def test_includes_display_views(self, mock_get_ocio_config_colorspaces): - mock_get_ocio_config_colorspaces.return_value = { - 'colorspaces': {}, - 'looks': {}, - 'roles': {}, - 'displays_views': { - 'sRGB (ACES)': { - 'view': 'sRGB', - 'display': 'ACES', - }, - }, - } - result = get_labeled_colorspaces( - 'config.ocio', - include_display_views=True - ) - self.assertEqual(result, [('sRGB (ACES)', '[view (display)] sRGB (ACES)')]) From 124c528c5ce4dcf9580defb0e0788e7548b2721c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 5 Oct 2023 18:24:22 +0200 Subject: [PATCH 0425/1224] colorspace: improving collected ocio lut data --- .../plugins/create/create_colorspace_look.py | 39 +++++++++++----- .../publish/collect_colorspace_look.py | 44 ++++++++++++++++--- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index 3e1c20d96a..0daffc728c 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -37,6 +37,7 @@ class CreateColorspaceLook(TrayPublishCreator): ] colorspace_attr_show = False config_items = None + config_data = None def get_detail_description(self): return """# Colorspace Look @@ -73,8 +74,20 @@ This creator publishes color space look file (LUT). # Create new instance new_instance = CreatedInstance(self.family, subset_name, instance_data, self) + new_instance.transient_data["config_items"] = self.config_items + new_instance.transient_data["config_data"] = self.config_data + self._store_new_instance(new_instance) + + def collect_instances(self): + super().collect_instances() + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + instance.transient_data["config_items"] = self.config_items + instance.transient_data["config_data"] = self.config_data + + def get_instance_attr_defs(self): return [ EnumDef( @@ -147,17 +160,21 @@ This creator publishes color space look file (LUT). project_settings=project_settings ) - if config_data: - filepath = config_data["path"] - config_items = colorspace.get_ocio_config_colorspaces(filepath) - labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( - config_items, - include_aliases=True, - include_roles=True - ) - self.config_items = config_items - self.colorspace_items.extend(labeled_colorspaces) - self.enabled = True + if not config_data: + self.enabled = False + return + + filepath = config_data["path"] + config_items = colorspace.get_ocio_config_colorspaces(filepath) + labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( + config_items, + include_aliases=True, + include_roles=True + ) + self.config_items = config_items + self.config_data = config_data + self.colorspace_items.extend(labeled_colorspaces) + self.enabled = True def _get_subset(self, asset_doc, variant, project_name, task_name=None): """Create subset name according to standard template process""" diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py index 739ab33f9c..4dc5348fb1 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -1,7 +1,8 @@ import os +from pprint import pformat import pyblish.api from openpype.pipeline import publish - +from openpype.pipeline import colorspace class CollectColorspaceLook(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin): @@ -19,11 +20,36 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, lut_repre_name = "LUTfile" file_url = creator_attrs["abs_lut_path"] file_name = os.path.basename(file_url) - _, ext = os.path.splitext(file_name) + base_name, ext = os.path.splitext(file_name) + + # set output name with base_name which was cleared + # of all symbols and all parts were capitalized + output_name = (base_name.replace("_", " ") + .replace(".", " ") + .replace("-", " ") + .title() + .replace(" ", "")) + + + # get config items + config_items = instance.data["transientData"]["config_items"] + config_data = instance.data["transientData"]["config_data"] + + # get colorspace items + converted_color_data = {} + for colorspace_key in [ + "working_colorspace", + "input_colorspace", + "output_colorspace" + ]: + color_data = colorspace.convert_colorspace_enumerator_item( + creator_attrs[colorspace_key], config_items) + converted_color_data[colorspace_key] = color_data # create lut representation data lut_repre = { "name": lut_repre_name, + "output": output_name, "ext": ext.lstrip("."), "files": file_name, "stagingDir": os.path.dirname(file_url), @@ -36,11 +62,17 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, { "name": lut_repre_name, "ext": ext.lstrip("."), - "working_colorspace": creator_attrs["working_colorspace"], - "input_colorspace": creator_attrs["input_colorspace"], - "output_colorspace": creator_attrs["output_colorspace"], + "working_colorspace": converted_color_data[ + "working_colorspace"], + "input_colorspace": converted_color_data[ + "input_colorspace"], + "output_colorspace": converted_color_data[ + "output_colorspace"], "direction": creator_attrs["direction"], - "interpolation": creator_attrs["interpolation"] + "interpolation": creator_attrs["interpolation"], + "config_data": config_data } ] }) + + self.log.debug(pformat(instance.data)) From 5b04af7ea138641bb5813ea7894044d03d8285c9 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 5 Oct 2023 19:30:44 +0300 Subject: [PATCH 0426/1224] 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 0427/1224] 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 0428/1224] 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 0429/1224] 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 0430/1224] 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 0431/1224] 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 0432/1224] 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 0433/1224] 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 0434/1224] 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 0435/1224] 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 0436/1224] 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 0437/1224] 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 0438/1224] 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 0439/1224] 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 0440/1224] 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 0441/1224] 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 0442/1224] 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 0443/1224] 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 0444/1224] 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 0445/1224] :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 0446/1224] 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 af67b4780f2daf62bafea4f227aea8e541d83dc6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 16:33:27 +0300 Subject: [PATCH 0447/1224] fabia's comments --- .../plugins/publish/collect_rop_frame_range.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 37db922201..d64ff37eb0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -43,26 +43,24 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, message = "" if attr_values.get("use_handles"): - message += ( + self.log.info( "Full Frame range with Handles " "{0[frameStartHandle]} - {0[frameEndHandle]}\n" .format(frame_data) ) else: - message += ( + self.log.info( "Use handles is deactivated for this instance, " "start and end handles are set to 0.\n" ) - message += ( + self.log.info( "Frame range {0[frameStart]} - {0[frameEnd]}" .format(frame_data) ) if frame_data.get("byFrameStep", 1.0) != 1.0: - message += "\nFrame steps {0[byFrameStep]}".format(frame_data) - - self.log.info(message) + self.log.info("Frame steps {0[byFrameStep]}".format(frame_data)) instance.data.update(frame_data) @@ -77,8 +75,8 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, def get_attribute_defs(cls): return [ BoolDef("use_handles", - tooltip="Disable this if you don't want the publisher" - " to ignore start and end handles specified in the" + tooltip="Disable this if you want the publisher to" + " ignore start and end handles specified in the" " asset data for this publish instance", default=True, label="Use asset handles") From 435ff3389f73ce0c0f39f5c1642ce61bd40d7bf6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Oct 2023 15:39:35 +0200 Subject: [PATCH 0448/1224] :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 0d47f4f57a5f472adbef5ef18c5f80b5c773d250 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 16:41:56 +0300 Subject: [PATCH 0449/1224] remove repair action --- .../plugins/publish/validate_frame_range.py | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index e1be99dbcf..cf85e59041 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -1,18 +1,11 @@ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError -from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectInvalidAction import hou -class HotFixAction(RepairAction): - """Set End frame to the minimum valid value.""" - - label = "End frame hotfix" - - class ValidateFrameRange(pyblish.api.InstancePlugin): """Validate Frame Range. @@ -24,7 +17,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder - 0.1 hosts = ["houdini"] label = "Validate Frame Range" - actions = [HotFixAction, SelectInvalidAction] + actions = [SelectInvalidAction] def process(self, instance): @@ -55,21 +48,3 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): ) ) return [rop_node] - - @classmethod - def repair(cls, instance): - rop_node = hou.node(instance.data["instance_node"]) - - frame_start = int(instance.data["frameStartHandle"]) - frame_end = int( - instance.data["frameStartHandle"] + - instance.data["handleStart"] + - instance.data["handleEnd"] - ) - - if rop_node.parm("f2").rawValue() == "$FEND": - hou.playbar.setFrameRange(frame_start, frame_end) - hou.playbar.setPlaybackRange(frame_start, frame_end) - hou.setFrame(frame_start) - else: - rop_node.parm("f2").set(frame_end) From 28c19f1a9b8ae39fe9746b3323f6118fbb71371c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 16:51:44 +0300 Subject: [PATCH 0450/1224] resolve hound --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index d64ff37eb0..f0a473995c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -40,8 +40,6 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return # Log artist friendly message about the collected frame range - message = "" - if attr_values.get("use_handles"): self.log.info( "Full Frame range with Handles " @@ -60,7 +58,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, ) if frame_data.get("byFrameStep", 1.0) != 1.0: - self.log.info("Frame steps {0[byFrameStep]}".format(frame_data)) + self.log.info("Frame steps {0[byFrameStep]}".format(frame_data)) instance.data.update(frame_data) From c51ed6409c27b017c357a0ccf91016103b6850d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 6 Oct 2023 15:51:47 +0200 Subject: [PATCH 0451/1224] 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 0452/1224] 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 4b02645618f1b31ec35452ceaa41dbcd5b623df6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 17:27:35 +0300 Subject: [PATCH 0453/1224] update frame data when rendering current frame --- openpype/hosts/houdini/api/lib.py | 19 +++++++++++-------- .../plugins/publish/collect_arnold_rop.py | 4 ++-- .../plugins/publish/collect_karma_rop.py | 4 ++-- .../plugins/publish/collect_mantra_rop.py | 4 ++-- .../plugins/publish/collect_redshift_rop.py | 4 ++-- .../plugins/publish/collect_vray_rop.py | 4 ++-- .../publish/submit_houdini_render_deadline.py | 4 ++-- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 711fae7cc4..d713782efe 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -573,22 +573,25 @@ def get_frame_data(node, asset_data=None, log=None): return data if node.evalParm("trange") == 0: + data["frameStartHandle"] = hou.intFrame() + data["frameEndHandle"] = hou.intFrame() + data["byFrameStep"] = 1.0 log.debug( - "Node '{}' has 'Render current frame' set. " - "Time range data ignored.".format(node.path()) - ) - return data + "Node '{}' has 'Render current frame' set. " + "frameStart and frameEnd are set to the " + "current frame".format(node.path()) + ) + else: + data["frameStartHandle"] = node.evalParm("f1") + data["frameEndHandle"] = node.evalParm("f2") + data["byFrameStep"] = node.evalParm("f3") - data["frameStartHandle"] = node.evalParm("f1") data["handleStart"] = asset_data.get("handleStart", 0) data["frameStart"] = data["frameStartHandle"] + data["handleStart"] - data["frameEndHandle"] = node.evalParm("f2") data["handleEnd"] = asset_data.get("handleEnd", 0) data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] - data["byFrameStep"] = node.evalParm("f3") - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 9933572f4a..28389c3b31 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -126,8 +126,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index 32790dd550..b66dcde13f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -95,8 +95,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index daaf87c04c..3b7cf59f32 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -118,8 +118,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index 5ade67d181..ca171a91f9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -132,8 +132,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index e5c6ec20c4..b1ff4c1886 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -115,8 +115,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index cd71095920..6f885c578a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -65,8 +65,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range - start = instance.data.get("frameStartHandle") - end = instance.data.get("frameEndHandle") + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] frames = "{start}-{end}x{step}".format( start=int(start), end=int(end), From b4a01faa65ebc7a08528eae75271159ac886c38f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 17:28:59 +0300 Subject: [PATCH 0454/1224] resolve hound --- 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 d713782efe..52d5fb4e03 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -577,10 +577,10 @@ def get_frame_data(node, asset_data=None, log=None): data["frameEndHandle"] = hou.intFrame() data["byFrameStep"] = 1.0 log.debug( - "Node '{}' has 'Render current frame' set. " - "frameStart and frameEnd are set to the " - "current frame".format(node.path()) - ) + "Node '{}' has 'Render current frame' set. " + "frameStart and frameEnd are set to the " + "current frame".format(node.path()) + ) else: data["frameStartHandle"] = node.evalParm("f1") data["frameEndHandle"] = node.evalParm("f2") From 787a0d1847e7aa5e78b5bc51d13cf68f31dd1379 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 6 Oct 2023 15:47:34 +0100 Subject: [PATCH 0455/1224] 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 0456/1224] 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 0457/1224] 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 f94cd729f6d88565f593979921748920e48e05ec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Oct 2023 17:49:20 +0200 Subject: [PATCH 0458/1224] Refactor to new style host --- openpype/hosts/resolve/api/menu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index ff8d5d4c6a..160cc44fdb 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -116,12 +116,12 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_save_current_clicked(self): host = registered_host() - current_file = host.current_file() + current_file = host.get_current_workfile() if not current_file: return print(f"Saving current file to: {current_file}") - host.save_file(current_file) + host.save_workfile(current_file) def on_workfile_clicked(self): print("Clicked Workfile") 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 0459/1224] 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 0460/1224] :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 47425e3aa460a012a4fc6265ff5b4fdfcc345fc5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 22:54:00 +0300 Subject: [PATCH 0461/1224] expose use asset handles to houdini settings --- .../plugins/publish/collect_rop_frame_range.py | 7 ++++--- .../defaults/project_settings/houdini.json | 3 +++ .../schemas/schema_houdini_publish.json | 17 +++++++++++++++++ .../houdini/server/settings/publish_plugins.py | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index f0a473995c..becadf2833 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -15,6 +15,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, hosts = ["houdini"] order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" + use_asset_handles = True def process(self, instance): @@ -40,7 +41,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return # Log artist friendly message about the collected frame range - if attr_values.get("use_handles"): + if attr_values.get("use_asset_handles"): self.log.info( "Full Frame range with Handles " "{0[frameStartHandle]} - {0[frameEndHandle]}\n" @@ -72,10 +73,10 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): return [ - BoolDef("use_handles", + BoolDef("use_asset_handles", tooltip="Disable this if you want the publisher to" " ignore start and end handles specified in the" " asset data for this publish instance", - default=True, + default=cls.use_asset_handles, label="Use asset handles") ] diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 4f57ee52c6..14fc0c1655 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -106,6 +106,9 @@ } }, "publish": { + "CollectRopFrameRange": { + "use_asset_handles": true + }, "ValidateWorkfilePaths": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index d5f70b0312..d030b3bdd3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -4,6 +4,23 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectRopFrameRange", + "label": "Collect Rop Frame Range", + "children": [ + { + "type": "label", + "label": "Disable this if you want the publisher to ignore start and end handles specified in the asset data for publish instances" + }, + { + "type": "boolean", + "key": "use_asset_handles", + "label": "Use asset handles" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 58240b0205..b975a9edfd 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -151,6 +151,16 @@ class ValidateWorkfilePathsModel(BaseSettingsModel): ) +class CollectRopFrameRangeModel(BaseSettingsModel): + """Collect Frame Range + + Disable this if you want the publisher to + ignore start and end handles specified in the + asset data for publish instances + """ + use_asset_handles: bool = Field(title="Use asset handles") + + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") @@ -158,6 +168,10 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): + CollectRopFrameRange:CollectRopFrameRangeModel = Field( + default_factory=CollectRopFrameRangeModel, + title="Collect Rop Frame Range." + ) ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( default_factory=ValidateWorkfilePathsModel, title="Validate workfile paths settings.") @@ -179,6 +193,9 @@ class PublishPluginsModel(BaseSettingsModel): DEFAULT_HOUDINI_PUBLISH_SETTINGS = { + "CollectRopFrameRange": { + "use_asset_handles": True + }, "ValidateWorkfilePaths": { "enabled": True, "optional": True, From ffb61e27efb222ec9f93f4c42b8491e7249fff3e Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 23:20:59 +0300 Subject: [PATCH 0462/1224] fix a bug --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index becadf2833..c9bc1766cb 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -41,7 +41,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return # Log artist friendly message about the collected frame range - if attr_values.get("use_asset_handles"): + if attr_values.get("use_handles"): self.log.info( "Full Frame range with Handles " "{0[frameStartHandle]} - {0[frameEndHandle]}\n" @@ -73,7 +73,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): return [ - BoolDef("use_asset_handles", + BoolDef("use_handles", tooltip="Disable this if you want the publisher to" " ignore start and end handles specified in the" " asset data for this publish instance", From 5f952c89d3933a61a335ae283f5ebe9ece398c09 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 6 Oct 2023 23:28:58 +0300 Subject: [PATCH 0463/1224] resolve hound and bump addon version --- server_addon/houdini/server/settings/publish_plugins.py | 2 +- server_addon/houdini/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index b975a9edfd..76030bdeea 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -168,7 +168,7 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): - CollectRopFrameRange:CollectRopFrameRangeModel = Field( + CollectRopFrameRange: CollectRopFrameRangeModel = Field( default_factory=CollectRopFrameRangeModel, title="Collect Rop Frame Range." ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" From 32052551e2c3848da47a3991c3eda985129f9059 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 7 Oct 2023 03:24:54 +0000 Subject: [PATCH 0464/1224] [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 0465/1224] 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 0466/1224] 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 0467/1224] 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 0468/1224] 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 0469/1224] 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 847f73deadd24f35b9f7567ea844cbc23db192fb Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 9 Oct 2023 10:52:13 +0300 Subject: [PATCH 0470/1224] ignore asset handles when rendering the current frame --- openpype/hosts/houdini/api/lib.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 56444afc12..6d57d959e6 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -580,8 +580,11 @@ def get_frame_data(node, asset_data=None, log=None): data["frameStartHandle"] = hou.intFrame() data["frameEndHandle"] = hou.intFrame() data["byFrameStep"] = 1.0 - log.debug( - "Node '{}' has 'Render current frame' set. " + data["handleStart"] = 0 + data["handleEnd"] = 0 + log.info( + "Node '{}' has 'Render current frame' set. \n" + "Asset Handles are ignored. \n" "frameStart and frameEnd are set to the " "current frame".format(node.path()) ) @@ -589,11 +592,10 @@ def get_frame_data(node, asset_data=None, log=None): data["frameStartHandle"] = node.evalParm("f1") data["frameEndHandle"] = node.evalParm("f2") data["byFrameStep"] = node.evalParm("f3") + data["handleStart"] = asset_data.get("handleStart", 0) + data["handleEnd"] = asset_data.get("handleEnd", 0) - data["handleStart"] = asset_data.get("handleStart", 0) data["frameStart"] = data["frameStartHandle"] + data["handleStart"] - - data["handleEnd"] = asset_data.get("handleEnd", 0) data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] return data 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 0471/1224] 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 0472/1224] 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 0473/1224] 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 0474/1224] 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 521707340af3e2357c7764d906da3659dec95e9d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 9 Oct 2023 12:30:58 +0300 Subject: [PATCH 0475/1224] allow using template keys in houdini shelves manager --- openpype/hosts/houdini/api/shelves.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 21e44e494a..c961b0242d 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -6,6 +6,9 @@ import platform from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name +from openpype.lib import StringTemplate +from openpype.pipeline.context_tools import get_current_context_template_data + import hou log = logging.getLogger("openpype.hosts.houdini.shelves") @@ -26,9 +29,15 @@ def generate_shelves(): log.debug("No custom shelves found in project settings.") return + # Get Template data + template_data = get_current_context_template_data() + for shelf_set_config in shelves_set_config: shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') shelf_set_os_filepath = shelf_set_filepath[current_os] + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) if shelf_set_os_filepath: if not os.path.isfile(shelf_set_os_filepath): log.error("Shelf path doesn't exist - " @@ -81,7 +90,7 @@ def generate_shelves(): "script path of the tool.") continue - tool = get_or_create_tool(tool_definition, shelf) + tool = get_or_create_tool(tool_definition, shelf, template_data) if not tool: continue @@ -144,7 +153,7 @@ def get_or_create_shelf(shelf_label): return new_shelf -def get_or_create_tool(tool_definition, shelf): +def get_or_create_tool(tool_definition, shelf, template_data): """This function verifies if the tool exists and updates it. If not, creates a new one. @@ -162,6 +171,7 @@ def get_or_create_tool(tool_definition, shelf): return script_path = tool_definition["script"] + script_path = get_path_using_template_data(script_path, template_data) if not script_path or not os.path.exists(script_path): log.warning("This path doesn't exist - {}".format(script_path)) return @@ -184,3 +194,10 @@ def get_or_create_tool(tool_definition, shelf): tool_name = re.sub(r"[^\w\d]+", "_", tool_label).lower() return hou.shelves.newTool(name=tool_name, **tool_definition) + + +def get_path_using_template_data(path, template_data): + path = StringTemplate.format_template(path, template_data) + path = path.replace("\\", "/") + + return path From 587beadd4d430ba2ae33f0a6fee8461f5e9abb7f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 9 Oct 2023 12:36:23 +0300 Subject: [PATCH 0476/1224] resolve hound --- openpype/hosts/houdini/api/shelves.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index c961b0242d..a93f8becfb 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -90,7 +90,9 @@ def generate_shelves(): "script path of the tool.") continue - tool = get_or_create_tool(tool_definition, shelf, template_data) + tool = get_or_create_tool( + tool_definition, shelf, template_data + ) if not tool: continue From 02e1bfd22ba501f28efa2dfff4a94c87a17630c7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 9 Oct 2023 18:20:34 +0800 Subject: [PATCH 0477/1224] add default channel data for tycache export --- .../max/plugins/publish/collect_tycache_attributes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index 122b0d6451..d735b2f2c0 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -55,11 +55,15 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, "tycacheSplines", "tycacheSplinesAdditionalSplines" ] - + tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos", + "tycacheChanRot", "tycacheChanScale", + "tycacheChanVel", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials"] return [ EnumDef("all_tyc_attrs", tyc_attr_enum, - default=None, + default=tyc_default_attrs, multiselection=True, label="TyCache Attributes"), TextDef("tycache_layer", From d208ef644ba9f7bd77619c09c3c9ef2124489f70 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 9 Oct 2023 12:46:25 +0100 Subject: [PATCH 0478/1224] Improved error reporting for sequence frame validator --- .../publish/validate_sequence_frames.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 96485d5a2d..6ba4ea0d2f 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -39,8 +39,20 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): collections, remainder = clique.assemble( repr["files"], minimum_items=1, patterns=patterns) - assert not remainder, "Must not have remainder" - assert len(collections) == 1, "Must detect single collection" + if remainder: + raise ValueError( + "Some files have been found outside a sequence." + f"Invalid files: {remainder}") + if not collections: + raise ValueError( + "No collections found. There should be a single " + "collection per representation.") + if len(collections) > 1: + raise ValueError( + "Multiple collections detected. There should be a single" + "collection per representation." + f"Collections identified: {collections}") + collection = collections[0] frames = list(collection.indexes) @@ -57,4 +69,7 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): f"expected: {required_range}") missing = collection.holes().indexes - assert not missing, "Missing frames: %s" % (missing,) + if missing: + raise ValueError( + "Missing frames have been detected." + f"Missing frames: {missing}") From 1824670bcce30df524820bb9880002e3f72d8b71 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 9 Oct 2023 14:46:32 +0300 Subject: [PATCH 0479/1224] save unnecessary call --- openpype/hosts/houdini/api/shelves.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index a93f8becfb..4d6a05b79d 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -35,10 +35,10 @@ def generate_shelves(): for shelf_set_config in shelves_set_config: shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') shelf_set_os_filepath = shelf_set_filepath[current_os] - shelf_set_os_filepath = get_path_using_template_data( - shelf_set_os_filepath, template_data - ) if shelf_set_os_filepath: + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) if not os.path.isfile(shelf_set_os_filepath): log.error("Shelf path doesn't exist - " "{}".format(shelf_set_os_filepath)) From 31f3e68349f287e9a9a8c4da6d6f7094f8712563 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 9 Oct 2023 12:53:24 +0100 Subject: [PATCH 0480/1224] Fixed some spacing issues in the error reports --- .../unreal/plugins/publish/validate_sequence_frames.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 6ba4ea0d2f..e24729391f 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -41,7 +41,7 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): if remainder: raise ValueError( - "Some files have been found outside a sequence." + "Some files have been found outside a sequence. " f"Invalid files: {remainder}") if not collections: raise ValueError( @@ -49,8 +49,8 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): "collection per representation.") if len(collections) > 1: raise ValueError( - "Multiple collections detected. There should be a single" - "collection per representation." + "Multiple collections detected. There should be a single " + "collection per representation. " f"Collections identified: {collections}") collection = collections[0] @@ -71,5 +71,5 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): missing = collection.holes().indexes if missing: raise ValueError( - "Missing frames have been detected." + "Missing frames have been detected. " f"Missing frames: {missing}") From 523633c1aaa1f3d54f518a5a3a3f2e8240f3479a Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 9 Oct 2023 12:53:46 +0100 Subject: [PATCH 0481/1224] Optional workfile dependency --- .../plugins/publish/submit_nuke_deadline.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 0295c2b760..0e57c54959 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -48,6 +48,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, use_gpu = False env_allowed_keys = [] env_search_replace_values = {} + workfile_dependency = True @classmethod def get_attribute_defs(cls): @@ -83,6 +84,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "suspend_publish", default=False, label="Suspend publish" + ), + BoolDef( + "workfile_dependency", + default=True, + label="Workfile Dependency" ) ] @@ -313,6 +319,13 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "AuxFiles": [] } + # Add workfile dependency. + workfile_dependency = instance.data["attributeValues"].get( + "workfile_dependency", self.workfile_dependency + ) + if workfile_dependency: + payload["JobInfo"].update({"AssetDependency0": script_path}) + # TODO: rewrite for baking with sequences if baking_submission: payload["JobInfo"].update({ From 0a71b89ddd857676d1561c3cdbfeb690ebae6103 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 13:57:53 +0200 Subject: [PATCH 0482/1224] 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 0483/1224] 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 0484/1224] 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 0485/1224] 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 0486/1224] 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 0487/1224] :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 0488/1224] :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 0489/1224] :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 9ff279d52f2a92471bf530a2664dafcbf9018c03 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 16:07:32 +0200 Subject: [PATCH 0490/1224] using `self.get_subset_name` rather then own function --- .../plugins/create/create_colorspace_look.py | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index 0daffc728c..a1b7896fba 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -14,10 +14,6 @@ from openpype.pipeline import ( CreatedInstance, CreatorError ) -from openpype.pipeline.create import ( - get_subset_name, - TaskNotSetError, -) from openpype.pipeline import colorspace from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator @@ -61,9 +57,11 @@ This creator publishes color space look file (LUT). asset_doc = get_asset_by_name( self.project_name, instance_data["asset"]) - subset_name = self._get_subset( - asset_doc, instance_data["variant"], self.project_name, - instance_data["task"] + subset_name = self.get_subset_name( + variant=instance_data["variant"], + task_name=instance_data["task"] or "Not set", + project_name=self.project_name, + asset_doc=asset_doc, ) instance_data["creator_attributes"] = { @@ -175,30 +173,3 @@ This creator publishes color space look file (LUT). self.config_data = config_data self.colorspace_items.extend(labeled_colorspaces) self.enabled = True - - def _get_subset(self, asset_doc, variant, project_name, task_name=None): - """Create subset name according to standard template process""" - - try: - subset_name = get_subset_name( - self.family, - variant, - task_name, - asset_doc, - project_name - ) - except TaskNotSetError: - # Create instance with fake task - # - instance will be marked as invalid so it can't be published - # but user have ability to change it - # NOTE: This expect that there is not task 'Undefined' on asset - task_name = "Undefined" - subset_name = get_subset_name( - self.family, - variant, - task_name, - asset_doc, - project_name - ) - - return subset_name From 5c6a7b6b25f49bdd7da2e372d5d3172844ca8948 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 16:13:25 +0200 Subject: [PATCH 0491/1224] hound suggestions --- openpype/pipeline/colorspace.py | 5 +++- ...pace_convert_colorspace_enumerator_item.py | 2 +- ...rspace_get_colorspaces_enumerator_items.py | 25 ++++++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 8bebc934fc..1dbe869ad9 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -573,7 +573,10 @@ def convert_colorspace_enumerator_item( # raise exception if item is not found if not item_data: message_config_keys = ", ".join( - "'{}':{}".format(key, set(config_items.get(key, {}).keys())) for key in config_items.keys() + "'{}':{}".format( + key, + set(config_items.get(key, {}).keys()) + ) for key in config_items.keys() ) raise KeyError( "Missing colorspace item '{}' in config data: [{}]".format( diff --git a/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py index bffe8eda90..56ac2a5d28 100644 --- a/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py +++ b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py @@ -1,4 +1,3 @@ -from ast import alias import unittest from openpype.pipeline.colorspace import convert_colorspace_enumerator_item @@ -114,5 +113,6 @@ class TestConvertColorspaceEnumeratorItem(unittest.TestCase): with self.assertRaises(KeyError): convert_colorspace_enumerator_item("RGB::sRGB", config_items) + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py index de3e333670..c221712d70 100644 --- a/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py +++ b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py @@ -45,7 +45,8 @@ class TestGetColorspacesEnumeratorItems(unittest.TestCase): self.assertEqual(result, expected) def test_aliases(self): - result = get_colorspaces_enumerator_items(self.config_items, include_aliases=True) + result = get_colorspaces_enumerator_items( + self.config_items, include_aliases=True) expected = [ ("colorspaces::Rec.709", "[colorspace] Rec.709"), ("colorspaces::sRGB", "[colorspace] sRGB"), @@ -56,7 +57,8 @@ class TestGetColorspacesEnumeratorItems(unittest.TestCase): self.assertEqual(result, expected) def test_looks(self): - result = get_colorspaces_enumerator_items(self.config_items, include_looks=True) + result = get_colorspaces_enumerator_items( + self.config_items, include_looks=True) expected = [ ("colorspaces::Rec.709", "[colorspace] Rec.709"), ("colorspaces::sRGB", "[colorspace] sRGB"), @@ -65,20 +67,22 @@ class TestGetColorspacesEnumeratorItems(unittest.TestCase): self.assertEqual(result, expected) def test_display_views(self): - result = get_colorspaces_enumerator_items(self.config_items, include_display_views=True) + result = get_colorspaces_enumerator_items( + self.config_items, include_display_views=True) expected = [ ("colorspaces::Rec.709", "[colorspace] Rec.709"), ("colorspaces::sRGB", "[colorspace] sRGB"), - ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501 ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), ] self.assertEqual(result, expected) def test_roles(self): - result = get_colorspaces_enumerator_items(self.config_items, include_roles=True) + result = get_colorspaces_enumerator_items( + self.config_items, include_roles=True) expected = [ - ("roles::compositing_linear", "[role] compositing_linear (linear)"), + ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501 ("colorspaces::Rec.709", "[colorspace] Rec.709"), ("colorspaces::sRGB", "[colorspace] sRGB"), ] @@ -86,7 +90,10 @@ class TestGetColorspacesEnumeratorItems(unittest.TestCase): def test_all(self): message_config_keys = ", ".join( - "'{}':{}".format(key, set(self.config_items.get(key, {}).keys())) for key in self.config_items.keys() + "'{}':{}".format( + key, + set(self.config_items.get(key, {}).keys()) + ) for key in self.config_items.keys() ) print("Testing with config: [{}]".format(message_config_keys)) result = get_colorspaces_enumerator_items( @@ -97,14 +104,14 @@ class TestGetColorspacesEnumeratorItems(unittest.TestCase): include_display_views=True, ) expected = [ - ("roles::compositing_linear", "[role] compositing_linear (linear)"), + ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501 ("colorspaces::Rec.709", "[colorspace] Rec.709"), ("colorspaces::sRGB", "[colorspace] sRGB"), ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"), ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"), ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"), ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"), - ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501 ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), ] self.assertEqual(result, expected) From b9185af47fa0f631ff054dff62b1bb865e03f7cf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 16:30:38 +0200 Subject: [PATCH 0492/1224] hound and docstring suggestion --- .../traypublisher/plugins/create/create_colorspace_look.py | 2 -- .../traypublisher/plugins/publish/collect_colorspace_look.py | 2 +- openpype/pipeline/colorspace.py | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index a1b7896fba..5628d0973f 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -77,7 +77,6 @@ This creator publishes color space look file (LUT). self._store_new_instance(new_instance) - def collect_instances(self): super().collect_instances() for instance in self.create_context.instances: @@ -85,7 +84,6 @@ This creator publishes color space look file (LUT). instance.transient_data["config_items"] = self.config_items instance.transient_data["config_data"] = self.config_data - def get_instance_attr_defs(self): return [ EnumDef( diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py index 4dc5348fb1..c7a886a619 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -4,6 +4,7 @@ import pyblish.api from openpype.pipeline import publish from openpype.pipeline import colorspace + class CollectColorspaceLook(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin): """Collect OCIO colorspace look from LUT file @@ -30,7 +31,6 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, .title() .replace(" ", "")) - # get config items config_items = instance.data["transientData"]["config_items"] config_data = instance.data["transientData"]["config_data"] diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 1dbe869ad9..82d9b17a37 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -600,7 +600,8 @@ def get_colorspaces_enumerator_items( Families can be used for building menu and submenus in gui. Args: - config_items (dict[str,dict]): colorspace data + config_items (dict[str,dict]): colorspace data coming from + `get_ocio_config_colorspaces` function include_aliases (bool): include aliases in result include_looks (bool): include looks in result include_roles (bool): include roles in result From 15aec6db161f86bf4258f2148fa853ccbfe24ad1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 9 Oct 2023 17:52:30 +0300 Subject: [PATCH 0493/1224] allow icon path to include template keys --- openpype/hosts/houdini/api/shelves.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4d6a05b79d..4b5ebd4202 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -178,6 +178,11 @@ def get_or_create_tool(tool_definition, shelf, template_data): log.warning("This path doesn't exist - {}".format(script_path)) return + icon_path = tool_definition["icon"] + if icon_path: + icon_path = get_path_using_template_data(icon_path, template_data) + tool_definition["icon"] = icon_path + existing_tools = shelf.tools() existing_tool = next( (tool for tool in existing_tools if tool.label() == tool_label), 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 0494/1224] 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 4ff71554d3c7f76c753f3c6e0796f367b3548dcb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 17:16:40 +0200 Subject: [PATCH 0495/1224] nuke: ocio look loader wip --- .../hosts/nuke/plugins/load/load_ociolook.py | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 openpype/hosts/nuke/plugins/load/load_ociolook.py diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py new file mode 100644 index 0000000000..76216e14cc --- /dev/null +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -0,0 +1,260 @@ +import json +from collections import OrderedDict +import nuke +import six + +from openpype.client import ( + get_version_by_id, + get_last_version_by_subset_id, +) +from openpype.pipeline import ( + load, + get_current_project_name, + get_representation_path, +) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) + + +class LoadOcioLook(load.LoaderPlugin): + """Loading Ocio look to the nuke node graph""" + + families = ["ociolook"] + representations = ["*"] + extension = {"json"} + + label = "Load OcioLook" + order = 0 + icon = "cc" + color = "white" + ignore_attr = ["useLifetime"] + + # json file variables + schema_version = 1 + + + def load(self, context, name, namespace, data): + """ + Loading function to get the soft effects to particular read node + + Arguments: + context (dict): context of version + name (str): name of the version + namespace (str): asset name + data (dict): compulsory attribute > not used + + Returns: + nuke node: containerised nuke node object + """ + # get main variables + version = context['version'] + version_data = version.get("data", {}) + vname = version.get("name", None) + root_working_colorspace = nuke.root()["working_colorspace"].value() + + namespace = namespace or context['asset']['name'] + object_name = "{}_{}".format(name, namespace) + + data_imprint = { + "version": vname, + "objectName": object_name, + "source": version_data.get("source", None), + "author": version_data.get("author", None), + "fps": version_data.get("fps", None), + } + + # getting file path + file = self.filepath_from_context(context).replace("\\", "/") + + # getting data from json file with unicode conversion + with open(file, "r") as f: + json_f = { + self.byteify(key): self.byteify(value) + for key, value in json.load(f).items() + } + + # check if the version in json_f is the same as plugin version + if json_f["version"] != self.schema_version: + raise KeyError( + "Version of json file is not the same as plugin version") + + json_data = json_f["data"] + ocio_working_colorspace = json_data["ocioLookWorkingSpace"] + + # adding nodes to node graph + # just in case we are in group lets jump out of it + nuke.endGroup() + + GN = nuke.createNode( + "Group", + "name {}_1".format(object_name), + inpanel=False + ) + + # adding content to the group node + with GN: + pre_colorspace = root_working_colorspace + pre_node = nuke.createNode("Input") + pre_node["name"].setValue("rgb") + + # Compare script working colorspace with ocio working colorspace + # found in json file and convert to json's if needed + if pre_colorspace != ocio_working_colorspace: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + ocio_working_colorspace + ) + pre_colorspace = ocio_working_colorspace + + for ocio_item in json_data["ocioLookItems"]: + input_space = _colorspace_name_by_type( + ocio_item["input_colorspace"]) + output_space = _colorspace_name_by_type( + ocio_item["output_colorspace"]) + + # making sure we are set to correct colorspace for otio item + if pre_colorspace != input_space: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + input_space + ) + + node = nuke.createNode("OCIOFileTransform") + + # TODO: file path from lut representation + node["file"].setValue(ocio_item["file"]) + node["name"].setValue(ocio_item["name"]) + node["direction"].setValue(ocio_item["direction"]) + node["interpolation"].setValue(ocio_item["interpolation"]) + node["working_space"].setValue(input_space) + + node.setInput(0, pre_node) + # pass output space into pre_colorspace for next iteration + # or for output node comparison + pre_colorspace = output_space + pre_node = node + + # making sure we are back in script working colorspace + if pre_colorspace != root_working_colorspace: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + root_working_colorspace + ) + + output = nuke.createNode("Output") + output.setInput(0, pre_node) + + GN["tile_color"].setValue(int("0x3469ffff", 16)) + + self.log.info("Loaded lut setup: `{}`".format(GN["name"].value())) + + return containerise( + node=GN, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + def update(self, container, representation): + """Update the Loader's path + + Nuke automatically tries to reset some variables when changing + the loader's path to a new file. These automatic changes are to its + inputs: + + """ + # get main variables + # Get version from io + project_name = get_current_project_name() + version_doc = get_version_by_id(project_name, representation["parent"]) + + # get corresponding node + GN = nuke.toNode(container['objectName']) + + file = get_representation_path(representation).replace("\\", "/") + name = container['name'] + version_data = version_doc.get("data", {}) + vname = version_doc.get("name", None) + namespace = container['namespace'] + object_name = "{}_{}".format(name, namespace) + + + def byteify(self, input): + """ + Converts unicode strings to strings + It goes through all dictionary + + Arguments: + input (dict/str): input + + Returns: + dict: with fixed values and keys + + """ + + if isinstance(input, dict): + return {self.byteify(key): self.byteify(value) + for key, value in input.items()} + elif isinstance(input, list): + return [self.byteify(element) for element in input] + elif isinstance(input, six.text_type): + return str(input) + else: + return input + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + node = nuke.toNode(container['objectName']) + with viewer_update_and_undo_stop(): + nuke.delete(node) + + +def _colorspace_name_by_type(colorspace_data): + """ + Returns colorspace name by type + + Arguments: + colorspace_data (dict): colorspace data + + Returns: + str: colorspace name + """ + if colorspace_data["type"] == "colorspaces": + return colorspace_data["name"] + elif colorspace_data["type"] == "roles": + return colorspace_data["colorspace"] + else: + raise KeyError("Unknown colorspace type: {}".format( + colorspace_data["type"])) + + + + +def _add_ocio_colorspace_node(pre_node, input_space, output_space): + """ + Adds OCIOColorSpace node to the node graph + + Arguments: + pre_node (nuke node): node to connect to + input_space (str): input colorspace + output_space (str): output colorspace + + Returns: + nuke node: node with OCIOColorSpace node + """ + node = nuke.createNode("OCIOColorSpace") + node.setInput(0, pre_node) + node["in_colorspace"].setValue(input_space) + node["out_colorspace"].setValue(output_space) + + node.setInput(0, pre_node) + return node From 5e01929cb652725cd8999854ed5c6ac3cdb5667b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 9 Oct 2023 17:35:25 +0200 Subject: [PATCH 0496/1224] ocio look loader wip2: final loader --- .../hosts/nuke/plugins/load/load_ociolook.py | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 76216e14cc..9f5a68dfc4 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -1,12 +1,9 @@ +import os import json -from collections import OrderedDict import nuke import six -from openpype.client import ( - get_version_by_id, - get_last_version_by_subset_id, -) +from openpype.client import get_version_by_id from openpype.pipeline import ( load, get_current_project_name, @@ -19,14 +16,14 @@ from openpype.hosts.nuke.api import ( ) -class LoadOcioLook(load.LoaderPlugin): +class LoadOcioLookNodes(load.LoaderPlugin): """Loading Ocio look to the nuke node graph""" families = ["ociolook"] representations = ["*"] - extension = {"json"} + extensions = {"json"} - label = "Load OcioLook" + label = "Load OcioLook [nodes]" order = 0 icon = "cc" color = "white" @@ -47,13 +44,13 @@ class LoadOcioLook(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerised nuke node object + nuke node: containerized nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) - root_working_colorspace = nuke.root()["working_colorspace"].value() + root_working_colorspace = nuke.root()["workingSpaceLUT"].value() namespace = namespace or context['asset']['name'] object_name = "{}_{}".format(name, namespace) @@ -67,14 +64,16 @@ class LoadOcioLook(load.LoaderPlugin): } # getting file path - file = self.filepath_from_context(context).replace("\\", "/") + file = self.filepath_from_context(context) + print(file) + + dir_path = os.path.dirname(file) + all_files = os.listdir(dir_path) # getting data from json file with unicode conversion with open(file, "r") as f: - json_f = { - self.byteify(key): self.byteify(value) - for key, value in json.load(f).items() - } + json_f = {self.bytify(key): self.bytify(value) + for key, value in json.load(f).items()} # check if the version in json_f is the same as plugin version if json_f["version"] != self.schema_version: @@ -82,7 +81,8 @@ class LoadOcioLook(load.LoaderPlugin): "Version of json file is not the same as plugin version") json_data = json_f["data"] - ocio_working_colorspace = json_data["ocioLookWorkingSpace"] + ocio_working_colorspace = _colorspace_name_by_type( + json_data["ocioLookWorkingSpace"]) # adding nodes to node graph # just in case we are in group lets jump out of it @@ -127,7 +127,19 @@ class LoadOcioLook(load.LoaderPlugin): node = nuke.createNode("OCIOFileTransform") # TODO: file path from lut representation - node["file"].setValue(ocio_item["file"]) + extension = ocio_item["ext"] + item_lut_file = next( + (file for file in all_files if file.endswith(extension)), + None + ) + if not item_lut_file: + raise ValueError( + "File with extension {} not found in directory".format( + extension)) + + item_lut_path = os.path.join( + dir_path, item_lut_file).replace("\\", "/") + node["file"].setValue(item_lut_path) node["name"].setValue(ocio_item["name"]) node["direction"].setValue(ocio_item["direction"]) node["interpolation"].setValue(ocio_item["interpolation"]) @@ -186,7 +198,7 @@ class LoadOcioLook(load.LoaderPlugin): object_name = "{}_{}".format(name, namespace) - def byteify(self, input): + def bytify(self, input): """ Converts unicode strings to strings It goes through all dictionary @@ -200,10 +212,10 @@ class LoadOcioLook(load.LoaderPlugin): """ if isinstance(input, dict): - return {self.byteify(key): self.byteify(value) + return {self.bytify(key): self.bytify(value) for key, value in input.items()} elif isinstance(input, list): - return [self.byteify(element) for element in input] + return [self.bytify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: 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 0497/1224] 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 0498/1224] 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 0499/1224] 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 0500/1224] 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 2f2e100231089b3bb321ed1e72962b929f75621f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Oct 2023 09:43:28 +0200 Subject: [PATCH 0501/1224] Fix Show in usdview loader action --- .../houdini/plugins/load/show_usdview.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py index 7b03a0738a..d56c4acc4f 100644 --- a/openpype/hosts/houdini/plugins/load/show_usdview.py +++ b/openpype/hosts/houdini/plugins/load/show_usdview.py @@ -1,4 +1,5 @@ import os +import platform import subprocess from openpype.lib.vendor_bin_utils import find_executable @@ -8,17 +9,31 @@ from openpype.pipeline import load class ShowInUsdview(load.LoaderPlugin): """Open USD file in usdview""" - families = ["colorbleed.usd"] label = "Show in usdview" - representations = ["usd", "usda", "usdlc", "usdnc"] - order = 10 + representations = ["*"] + families = ["*"] + extensions = {"usd", "usda", "usdlc", "usdnc", "abc"} + order = 15 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): + from pathlib import Path - usdview = find_executable("usdview") + if platform.system() == "Windows": + executable = "usdview.bat" + else: + executable = "usdview" + + usdview = find_executable(executable) + if not usdview: + raise RuntimeError("Unable to find usdview") + + # For some reason Windows can return the path like: + # C:/PROGRA~1/SIDEEF~1/HOUDIN~1.435/bin/usdview + # convert to resolved path so `subprocess` can take it + usdview = str(Path(usdview).resolve().as_posix()) filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) @@ -30,14 +45,4 @@ class ShowInUsdview(load.LoaderPlugin): self.log.info("Start houdini variant of usdview...") - # For now avoid some pipeline environment variables that initialize - # Avalon in Houdini as it is redundant for usdview and slows boot time - env = os.environ.copy() - env.pop("PYTHONPATH", None) - env.pop("HOUDINI_SCRIPT_PATH", None) - env.pop("HOUDINI_MENU_PATH", None) - - # Force string to avoid unicode issues - env = {str(key): str(value) for key, value in env.items()} - - subprocess.Popen([usdview, filepath, "--renderer", "GL"], env=env) + subprocess.Popen([usdview, filepath, "--renderer", "GL"]) From 067aa2ca4d4961e6d00c13c80ea67ed4045ae2b7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Oct 2023 10:49:39 +0200 Subject: [PATCH 0502/1224] 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 0503/1224] 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 58e5cf20b3023ea0c440304b2ba5184af6110312 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 14:34:35 +0200 Subject: [PATCH 0504/1224] loader ociolook with updating --- .../hosts/nuke/plugins/load/load_ociolook.py | 209 +++++++++++------- 1 file changed, 130 insertions(+), 79 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 9f5a68dfc4..6cf9236e1b 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -1,5 +1,6 @@ import os import json +import secrets import nuke import six @@ -17,7 +18,7 @@ from openpype.hosts.nuke.api import ( class LoadOcioLookNodes(load.LoaderPlugin): - """Loading Ocio look to the nuke node graph""" + """Loading Ocio look to the nuke.Node graph""" families = ["ociolook"] representations = ["*"] @@ -27,7 +28,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): order = 0 icon = "cc" color = "white" - ignore_attr = ["useLifetime"] + igroup_nodeore_attr = ["useLifetime"] # json file variables schema_version = 1 @@ -44,61 +45,98 @@ class LoadOcioLookNodes(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerized nuke node object + nuke.Node: containerized nuke.Node object """ - # get main variables - version = context['version'] - version_data = version.get("data", {}) - vname = version.get("name", None) - root_working_colorspace = nuke.root()["workingSpaceLUT"].value() - namespace = namespace or context['asset']['name'] - object_name = "{}_{}".format(name, namespace) - - data_imprint = { - "version": vname, - "objectName": object_name, - "source": version_data.get("source", None), - "author": version_data.get("author", None), - "fps": version_data.get("fps", None), - } + suffix = secrets.token_hex(nbytes=4) + object_name = "{}_{}_{}".format( + name, namespace, suffix) # getting file path - file = self.filepath_from_context(context) - print(file) + filepath = self.filepath_from_context(context) - dir_path = os.path.dirname(file) + json_f = self._load_json_data(filepath) + + group_node = self._create_group_node( + object_name, filepath, json_f["data"]) + + group_node["tile_color"].setValue(int("0x3469ffff", 16)) + + self.log.info("Loaded lut setup: `{}`".format(group_node["name"].value())) + + return containerise( + node=group_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data={ + "objectName": object_name, + } + ) + + def _create_group_node( + self, + object_name, + filepath, + data + ): + """Creates group node with all the nodes inside. + + Creating mainly `OCIOFileTransform` nodes with `OCIOColorSpace` nodes + in between - in case those are needed. + + Arguments: + object_name (str): name of the group node + filepath (str): path to json file + data (dict): data from json file + + Returns: + nuke.Node: group node with all the nodes inside + """ + # get corresponding node + + root_working_colorspace = nuke.root()["workingSpaceLUT"].value() + + dir_path = os.path.dirname(filepath) all_files = os.listdir(dir_path) - # getting data from json file with unicode conversion - with open(file, "r") as f: - json_f = {self.bytify(key): self.bytify(value) - for key, value in json.load(f).items()} - - # check if the version in json_f is the same as plugin version - if json_f["version"] != self.schema_version: - raise KeyError( - "Version of json file is not the same as plugin version") - - json_data = json_f["data"] ocio_working_colorspace = _colorspace_name_by_type( - json_data["ocioLookWorkingSpace"]) + data["ocioLookWorkingSpace"]) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() - GN = nuke.createNode( - "Group", - "name {}_1".format(object_name), - inpanel=False - ) + input_node = None + output_node = None + group_node = nuke.toNode(object_name) + if group_node: + # remove all nodes between Input and Output nodes + for node in group_node.nodes(): + if node.Class() not in ["Input", "Output"]: + nuke.delete(node) + if node.Class() == "Input": + input_node = node + if node.Class() == "Output": + output_node = node + else: + group_node = nuke.createNode( + "Group", + "name {}_1".format(object_name), + inpanel=False + ) # adding content to the group node - with GN: + with group_node: pre_colorspace = root_working_colorspace - pre_node = nuke.createNode("Input") - pre_node["name"].setValue("rgb") + + # reusing input node if it exists during update + if input_node: + pre_node = input_node + else: + pre_node = nuke.createNode("Input") + pre_node["name"].setValue("rgb") # Compare script working colorspace with ocio working colorspace # found in json file and convert to json's if needed @@ -110,7 +148,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): ) pre_colorspace = ocio_working_colorspace - for ocio_item in json_data["ocioLookItems"]: + for ocio_item in data["ocioLookItems"]: input_space = _colorspace_name_by_type( ocio_item["input_colorspace"]) output_space = _colorspace_name_by_type( @@ -126,10 +164,16 @@ class LoadOcioLookNodes(load.LoaderPlugin): node = nuke.createNode("OCIOFileTransform") - # TODO: file path from lut representation + # file path from lut representation extension = ocio_item["ext"] + item_name = ocio_item["name"] + item_lut_file = next( - (file for file in all_files if file.endswith(extension)), + ( + file for file in all_files + if file.endswith(extension) + and item_name in file + ), None ) if not item_lut_file: @@ -140,12 +184,14 @@ class LoadOcioLookNodes(load.LoaderPlugin): item_lut_path = os.path.join( dir_path, item_lut_file).replace("\\", "/") node["file"].setValue(item_lut_path) - node["name"].setValue(ocio_item["name"]) + node["name"].setValue(item_name) node["direction"].setValue(ocio_item["direction"]) node["interpolation"].setValue(ocio_item["interpolation"]) node["working_space"].setValue(input_space) + pre_node.autoplace() node.setInput(0, pre_node) + node.autoplace() # pass output space into pre_colorspace for next iteration # or for output node comparison pre_colorspace = output_space @@ -159,46 +205,48 @@ class LoadOcioLookNodes(load.LoaderPlugin): root_working_colorspace ) - output = nuke.createNode("Output") + # reusing output node if it exists during update + if not output_node: + output = nuke.createNode("Output") + else: + output = output_node + output.setInput(0, pre_node) - GN["tile_color"].setValue(int("0x3469ffff", 16)) - - self.log.info("Loaded lut setup: `{}`".format(GN["name"].value())) - - return containerise( - node=GN, - name=name, - namespace=namespace, - context=context, - loader=self.__class__.__name__, - data=data_imprint) + return group_node def update(self, container, representation): - """Update the Loader's path - Nuke automatically tries to reset some variables when changing - the loader's path to a new file. These automatic changes are to its - inputs: + object_name = container['objectName'] - """ - # get main variables - # Get version from io - project_name = get_current_project_name() - version_doc = get_version_by_id(project_name, representation["parent"]) + filepath = get_representation_path(representation) - # get corresponding node - GN = nuke.toNode(container['objectName']) + json_f = self._load_json_data(filepath) - file = get_representation_path(representation).replace("\\", "/") - name = container['name'] - version_data = version_doc.get("data", {}) - vname = version_doc.get("name", None) - namespace = container['namespace'] - object_name = "{}_{}".format(name, namespace) + new_group_node = self._create_group_node( + object_name, + filepath, + json_f["data"] + ) + + self.log.info("Updated lut setup: `{}`".format( + new_group_node["name"].value())) - def bytify(self, input): + def _load_json_data(self, filepath): + # getting data from json file with unicode conversion + with open(filepath, "r") as _file: + json_f = {self._bytify(key): self._bytify(value) + for key, value in json.load(_file).items()} + + # check if the version in json_f is the same as plugin version + if json_f["version"] != self.schema_version: + raise KeyError( + "Version of json file is not the same as plugin version") + + return json_f + + def _bytify(self, input): """ Converts unicode strings to strings It goes through all dictionary @@ -212,10 +260,10 @@ class LoadOcioLookNodes(load.LoaderPlugin): """ if isinstance(input, dict): - return {self.bytify(key): self.bytify(value) + return {self._bytify(key): self._bytify(value) for key, value in input.items()} elif isinstance(input, list): - return [self.bytify(element) for element in input] + return [self._bytify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: @@ -256,17 +304,20 @@ def _add_ocio_colorspace_node(pre_node, input_space, output_space): Adds OCIOColorSpace node to the node graph Arguments: - pre_node (nuke node): node to connect to + pre_node (nuke.Node): node to connect to input_space (str): input colorspace output_space (str): output colorspace Returns: - nuke node: node with OCIOColorSpace node + nuke.Node: node with OCIOColorSpace node """ node = nuke.createNode("OCIOColorSpace") node.setInput(0, pre_node) node["in_colorspace"].setValue(input_space) node["out_colorspace"].setValue(output_space) + pre_node.autoplace() node.setInput(0, pre_node) + node.autoplace() + return node From e422d1900f10acc3c70bd5377a4930bedf2e329c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 17:18:14 +0200 Subject: [PATCH 0505/1224] adding color to loaded nodes --- .../hosts/nuke/plugins/load/load_ociolook.py | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 6cf9236e1b..d2143b5527 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -4,7 +4,10 @@ import secrets import nuke import six -from openpype.client import get_version_by_id +from openpype.client import ( + get_version_by_id, + get_last_version_by_subset_id +) from openpype.pipeline import ( load, get_current_project_name, @@ -12,7 +15,6 @@ from openpype.pipeline import ( ) from openpype.hosts.nuke.api import ( containerise, - update_container, viewer_update_and_undo_stop ) @@ -28,7 +30,11 @@ class LoadOcioLookNodes(load.LoaderPlugin): order = 0 icon = "cc" color = "white" - igroup_nodeore_attr = ["useLifetime"] + ignore_attr = ["useLifetime"] + + # plugin attributes + current_node_color = "0x4ecd91ff" + old_node_color = "0xd88467ff" # json file variables schema_version = 1 @@ -60,7 +66,8 @@ class LoadOcioLookNodes(load.LoaderPlugin): group_node = self._create_group_node( object_name, filepath, json_f["data"]) - group_node["tile_color"].setValue(int("0x3469ffff", 16)) + + self._node_version_color(context["version"], group_node) self.log.info("Loaded lut setup: `{}`".format(group_node["name"].value())) @@ -217,20 +224,25 @@ class LoadOcioLookNodes(load.LoaderPlugin): def update(self, container, representation): + project_name = get_current_project_name() + version_doc = get_version_by_id(project_name, representation["parent"]) + object_name = container['objectName'] filepath = get_representation_path(representation) json_f = self._load_json_data(filepath) - new_group_node = self._create_group_node( + group_node = self._create_group_node( object_name, filepath, json_f["data"] ) + self._node_version_color(version_doc, group_node) + self.log.info("Updated lut setup: `{}`".format( - new_group_node["name"].value())) + group_node["name"].value())) def _load_json_data(self, filepath): @@ -277,6 +289,20 @@ class LoadOcioLookNodes(load.LoaderPlugin): with viewer_update_and_undo_stop(): nuke.delete(node) + def _node_version_color(self, version, node): + """ Coloring a node by correct color by actual version""" + + project_name = get_current_project_name() + last_version_doc = get_last_version_by_subset_id( + project_name, version["parent"], fields=["_id"] + ) + + # change color of node + if version["_id"] == last_version_doc["_id"]: + color_value = self.current_node_color + else: + color_value = self.old_node_color + node["tile_color"].setValue(int(color_value, 16)) def _colorspace_name_by_type(colorspace_data): """ From ac9ead71fdc5c0e326bd46132707fb9fd08cd20f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 10 Oct 2023 17:21:06 +0200 Subject: [PATCH 0506/1224] hound --- openpype/hosts/nuke/plugins/load/load_ociolook.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index d2143b5527..29503ef4de 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -323,8 +323,6 @@ def _colorspace_name_by_type(colorspace_data): colorspace_data["type"])) - - def _add_ocio_colorspace_node(pre_node, input_space, output_space): """ Adds OCIOColorSpace node to the node graph From b49c04f5706a2d21755d646e02d80a3651a43f9b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 10 Oct 2023 17:56:47 +0100 Subject: [PATCH 0507/1224] 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 0508/1224] 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 0509/1224] 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 0510/1224] [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 0511/1224] 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 519db56b8769a71f18083a1cb972c2e9ae0f567c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 09:38:48 +0300 Subject: [PATCH 0512/1224] re-arrange settings files --- .../{publish_plugins.py => create.py} | 85 +------------------ server_addon/houdini/server/settings/main.py | 64 +++----------- .../houdini/server/settings/publish.py | 84 ++++++++++++++++++ .../houdini/server/settings/shelves.py | 43 ++++++++++ server_addon/houdini/server/version.py | 2 +- 5 files changed, 141 insertions(+), 137 deletions(-) rename server_addon/houdini/server/settings/{publish_plugins.py => create.py} (61%) create mode 100644 server_addon/houdini/server/settings/publish.py create mode 100644 server_addon/houdini/server/settings/shelves.py diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/create.py similarity index 61% rename from server_addon/houdini/server/settings/publish_plugins.py rename to server_addon/houdini/server/settings/create.py index 58240b0205..a1f8d24c30 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/create.py @@ -1,5 +1,4 @@ from pydantic import Field - from ayon_server.settings import BaseSettingsModel @@ -37,7 +36,7 @@ class CreateStaticMeshModel(BaseSettingsModel): class CreatePluginsModel(BaseSettingsModel): CreateArnoldAss: CreateArnoldAssModel = Field( default_factory=CreateArnoldAssModel, - title="Create Alembic Camera") + title="Create Arnold Ass") # "-" is not compatible in the new model CreateStaticMesh: CreateStaticMeshModel = Field( default_factory=CreateStaticMeshModel, @@ -135,85 +134,3 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"] }, } - - -# Publish Plugins -class ValidateWorkfilePathsModel(BaseSettingsModel): - enabled: bool = Field(title="Enabled") - optional: bool = Field(title="Optional") - node_types: list[str] = Field( - default_factory=list, - title="Node Types" - ) - prohibited_vars: list[str] = Field( - default_factory=list, - title="Prohibited Variables" - ) - - -class BasicValidateModel(BaseSettingsModel): - enabled: bool = Field(title="Enabled") - optional: bool = Field(title="Optional") - active: bool = Field(title="Active") - - -class PublishPluginsModel(BaseSettingsModel): - ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( - default_factory=ValidateWorkfilePathsModel, - title="Validate workfile paths settings.") - ValidateReviewColorspace: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Review Colorspace.") - ValidateContainers: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Latest Containers.") - ValidateSubsetName: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Subset Name.") - ValidateMeshIsStatic: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Mesh is Static.") - ValidateUnrealStaticMeshName: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Unreal Static Mesh Name.") - - -DEFAULT_HOUDINI_PUBLISH_SETTINGS = { - "ValidateWorkfilePaths": { - "enabled": True, - "optional": True, - "node_types": [ - "file", - "alembic" - ], - "prohibited_vars": [ - "$HIP", - "$JOB" - ] - }, - "ValidateReviewColorspace": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateContainers": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateSubsetName": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateMeshIsStatic": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateUnrealStaticMeshName": { - "enabled": False, - "optional": True, - "active": True - } -} diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index 0c2e160c87..9cfec54f22 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -1,57 +1,19 @@ from pydantic import Field -from ayon_server.settings import ( - BaseSettingsModel, - MultiplatformPathModel, - MultiplatformPathListModel, -) +from ayon_server.settings import BaseSettingsModel from .general import ( GeneralSettingsModel, DEFAULT_GENERAL_SETTINGS ) from .imageio import HoudiniImageIOModel -from .publish_plugins import ( - PublishPluginsModel, +from .shelves import ShelvesModel +from .create import ( CreatePluginsModel, - DEFAULT_HOUDINI_PUBLISH_SETTINGS, DEFAULT_HOUDINI_CREATE_SETTINGS ) - - -class ShelfToolsModel(BaseSettingsModel): - name: str = Field(title="Name") - help: str = Field(title="Help text") - script: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Script Path " - ) - icon: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Icon Path " - ) - - -class ShelfDefinitionModel(BaseSettingsModel): - _layout = "expanded" - shelf_name: str = Field(title="Shelf name") - tools_list: list[ShelfToolsModel] = Field( - default_factory=list, - title="Shelf Tools" - ) - - -class ShelvesModel(BaseSettingsModel): - _layout = "expanded" - shelf_set_name: str = Field(title="Shelfs set name") - - shelf_set_source_path: MultiplatformPathListModel = Field( - default_factory=MultiplatformPathListModel, - title="Shelf Set Path (optional)" - ) - - shelf_definition: list[ShelfDefinitionModel] = Field( - default_factory=list, - title="Shelf Definitions" - ) +from .publish import ( + PublishPluginsModel, + DEFAULT_HOUDINI_PUBLISH_SETTINGS, +) class HoudiniSettings(BaseSettingsModel): @@ -65,18 +27,16 @@ class HoudiniSettings(BaseSettingsModel): ) shelves: list[ShelvesModel] = Field( default_factory=list, - title="Houdini Scripts Shelves", + title="Shelves Manager", ) - - publish: PublishPluginsModel = Field( - default_factory=PublishPluginsModel, - title="Publish Plugins", - ) - create: CreatePluginsModel = Field( default_factory=CreatePluginsModel, title="Creator Plugins", ) + publish: PublishPluginsModel = Field( + default_factory=PublishPluginsModel, + title="Publish Plugins", + ) DEFAULT_VALUES = { diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py new file mode 100644 index 0000000000..7612c446bf --- /dev/null +++ b/server_addon/houdini/server/settings/publish.py @@ -0,0 +1,84 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + +# Publish Plugins +class ValidateWorkfilePathsModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + node_types: list[str] = Field( + default_factory=list, + title="Node Types" + ) + prohibited_vars: list[str] = Field( + default_factory=list, + title="Prohibited Variables" + ) + + +class BasicValidateModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + + +class PublishPluginsModel(BaseSettingsModel): + ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( + default_factory=ValidateWorkfilePathsModel, + title="Validate workfile paths settings.") + ValidateReviewColorspace: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Review Colorspace.") + ValidateContainers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Latest Containers.") + ValidateSubsetName: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Subset Name.") + ValidateMeshIsStatic: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Mesh is Static.") + ValidateUnrealStaticMeshName: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Unreal Static Mesh Name.") + + +DEFAULT_HOUDINI_PUBLISH_SETTINGS = { + "ValidateWorkfilePaths": { + "enabled": True, + "optional": True, + "node_types": [ + "file", + "alembic" + ], + "prohibited_vars": [ + "$HIP", + "$JOB" + ] + }, + "ValidateReviewColorspace": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateContainers": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSubsetName": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateMeshIsStatic": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateUnrealStaticMeshName": { + "enabled": False, + "optional": True, + "active": True + } +} diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py new file mode 100644 index 0000000000..2319357f59 --- /dev/null +++ b/server_addon/houdini/server/settings/shelves.py @@ -0,0 +1,43 @@ +from pydantic import Field +from ayon_server.settings import ( + BaseSettingsModel, + MultiplatformPathModel, + MultiplatformPathListModel, +) + + +class ShelfToolsModel(BaseSettingsModel): + name: str = Field(title="Name") + help: str = Field(title="Help text") + script: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Script Path " + ) + icon: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Icon Path " + ) + + +class ShelfDefinitionModel(BaseSettingsModel): + _layout = "expanded" + shelf_name: str = Field(title="Shelf name") + tools_list: list[ShelfToolsModel] = Field( + default_factory=list, + title="Shelf Tools" + ) + + +class ShelvesModel(BaseSettingsModel): + _layout = "expanded" + shelf_set_name: str = Field(title="Shelfs set name") + + shelf_set_source_path: MultiplatformPathListModel = Field( + default_factory=MultiplatformPathListModel, + title="Shelf Set Path (optional)" + ) + + shelf_definition: list[ShelfDefinitionModel] = Field( + default_factory=list, + title="Shelf Definitions" + ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" From b1b24d49b00f4369fcd15e77b471d352bedf684f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 10:45:27 +0300 Subject: [PATCH 0513/1224] use int frameStartHandle and frameEndHandle --- 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 6d57d959e6..7ff86fe5ae 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -589,8 +589,8 @@ def get_frame_data(node, asset_data=None, log=None): "current frame".format(node.path()) ) else: - data["frameStartHandle"] = node.evalParm("f1") - data["frameEndHandle"] = node.evalParm("f2") + data["frameStartHandle"] = int(node.evalParm("f1")) + data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") data["handleStart"] = asset_data.get("handleStart", 0) data["handleEnd"] = asset_data.get("handleEnd", 0) From f52dc88a17415c667220dc3e3ef4fc3f5b14a074 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 12:03:05 +0300 Subject: [PATCH 0514/1224] fix typo --- 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 7ff86fe5ae..8810e1520f 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -559,7 +559,7 @@ def get_frame_data(node, asset_data=None, log=None): node(hou.Node) Returns: - dict: frame data for star, end and steps. + dict: frame data for start, end and steps. """ if asset_data is None: From 99dcca56d55035a7ddec249cd7bf85e9a4168e6b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 12:03:50 +0300 Subject: [PATCH 0515/1224] better error message --- .../plugins/publish/validate_frame_range.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index cf85e59041..30b736dbd0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -23,10 +23,25 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: - nodes = [n.path() for n in invalid] + node = invalid[0].path() raise PublishValidationError( - "Invalid Frame Range on: {0}".format(nodes), - title="Invalid Frame Range" + title="Invalid Frame Range", + message=( + "Invalid frame range because the instance start frame ({0[frameStart]}) " + "is higher than the end frame ({0[frameEnd]})" + .format(instance.data) + ), + description=( + "## Invalid Frame Range\n" + "The frame range for the instance is invalid because the " + "start frame is higher than the end frame.\n\nThis is likely " + "due to asset handles being applied to your instance or may " + "be because the ROP node's start frame is set higher than the " + "end frame.\n\nIf your ROP frame range is correct and you do " + "not want to apply asset handles make sure to disable Use " + "asset handles on the publish instance.\n\n" + "Associated Node: \"{0}\"".format(node) + ) ) @classmethod @@ -37,14 +52,11 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): rop_node = hou.node(instance.data["instance_node"]) if instance.data["frameStart"] > instance.data["frameEnd"]: - cls.log.error( - "Wrong frame range, please consider handle start and end.\n" - "frameEnd should at least be {}.\n" - "Use \"End frame hotfix\" action to do that." - .format( - instance.data["handleEnd"] + - instance.data["handleStart"] + - instance.data["frameStartHandle"] - ) + cls.log.info( + "The ROP node render range is set to " + "{0[frameStartHandle]} - {0[frameEndHandle]} " + "The asset handles applied to the instance are start handle " + "{0[handleStart]} and end handle {0[handleEnd]}" + .format(instance.data) ) return [rop_node] From b2f613966cf7b6014d0fba47a134365a9079faf3 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 12:07:56 +0300 Subject: [PATCH 0516/1224] rexolve hound --- .../plugins/publish/validate_frame_range.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 30b736dbd0..936eb1180d 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -27,20 +27,22 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): raise PublishValidationError( title="Invalid Frame Range", message=( - "Invalid frame range because the instance start frame ({0[frameStart]}) " - "is higher than the end frame ({0[frameEnd]})" + "Invalid frame range because the instance " + "start frame ({0[frameStart]}) is higher than " + "the end frame ({0[frameEnd]})" .format(instance.data) ), description=( "## Invalid Frame Range\n" - "The frame range for the instance is invalid because the " - "start frame is higher than the end frame.\n\nThis is likely " - "due to asset handles being applied to your instance or may " - "be because the ROP node's start frame is set higher than the " - "end frame.\n\nIf your ROP frame range is correct and you do " - "not want to apply asset handles make sure to disable Use " - "asset handles on the publish instance.\n\n" - "Associated Node: \"{0}\"".format(node) + "The frame range for the instance is invalid because " + "the start frame is higher than the end frame.\n\nThis " + "is likely due to asset handles being applied to your " + "instance or may be because the ROP node's start frame " + "is set higher than the end frame.\n\nIf your ROP frame " + "range is correct and you do not want to apply asset " + "handles make sure to disable Use asset handles on the " + "publish instance.\n\nAssociated Node: \"{0}\"" + .format(node) ) ) From b06935efb74a6a0bb4c9e1865e61bc64d2e6be12 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 11 Oct 2023 17:13:18 +0800 Subject: [PATCH 0517/1224] Enhancement on the setting in review family --- openpype/hosts/max/api/lib.py | 3 + .../hosts/max/plugins/create/create_review.py | 46 +++++++++---- .../max/plugins/publish/collect_review.py | 7 +- .../publish/extract_review_animation.py | 65 +++++++++++++++---- .../max/plugins/publish/extract_thumbnail.py | 11 +++- 5 files changed, 102 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8b70b3ced7..6f41cf9260 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -324,6 +324,7 @@ def is_headless(): @contextlib.contextmanager def viewport_camera(camera): original = rt.viewport.getCamera() + has_autoplay = rt.preferences.playPreviewWhenDone if not original: # if there is no original camera # use the current camera as original @@ -331,9 +332,11 @@ def viewport_camera(camera): review_camera = rt.getNodeByName(camera) try: rt.viewport.setCamera(review_camera) + rt.preferences.playPreviewWhenDone = False yield finally: rt.viewport.setCamera(original) + rt.preferences.playPreviewWhenDone = has_autoplay def set_timeline(frameStart, frameEnd): diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 5737114fcc..5638327d72 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -17,27 +17,41 @@ class CreateReview(plugin.MaxCreator): instance_data["imageFormat"] = pre_create_data.get("imageFormat") instance_data["keepImages"] = pre_create_data.get("keepImages") instance_data["percentSize"] = pre_create_data.get("percentSize") - instance_data["rndLevel"] = pre_create_data.get("rndLevel") + instance_data["visualStyleMode"] = pre_create_data.get("visualStyleMode") + # Transfer settings from pre create to instance + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + for key in ["imageFormat", + "keepImages", + "percentSize", + "visualStyleMode", + "viewportPreset"]: + if key in pre_create_data: + creator_attributes[key] = pre_create_data[key] super(CreateReview, self).create( subset_name, instance_data, pre_create_data) - def get_pre_create_attr_defs(self): - attrs = super(CreateReview, self).get_pre_create_attr_defs() - + def get_instance_attr_defs(self): image_format_enum = [ "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png", "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg" ] - rndLevel_enum = [ - "smoothhighlights", "smooth", "facethighlights", - "facet", "flat", "litwireframe", "wireframe", "box" + visual_style_preset_enum = [ + "Realistic", "Shaded", "Facets", + "ConsistentColors", "HiddenLine", + "Wireframe", "BoundingBox", "Ink", + "ColorInk", "Acrylic", "Tech", "Graphite", + "ColorPencil", "Pastel", "Clay", "ModelAssist" ] + preview_preset_enum = [ + "Quality", "Standard", "Performance", + "DXMode", "Customize"] - return attrs + [ + return [ BoolDef("keepImages", label="Keep Image Sequences", default=False), @@ -50,8 +64,16 @@ class CreateReview(plugin.MaxCreator): default=100, minimum=1, decimals=0), - EnumDef("rndLevel", - rndLevel_enum, - default="smoothhighlights", - label="Preference") + EnumDef("visualStyleMode", + visual_style_preset_enum, + default="Realistic", + label="Preference"), + EnumDef("viewportPreset", + preview_preset_enum, + default="Quality", + label="Pre-View Preset") ] + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attributes + return self.get_instance_attr_defs() diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 8e27a857d7..1f0dca5329 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -25,10 +25,15 @@ class CollectReview(pyblish.api.InstancePlugin, if rt.classOf(node) in rt.Camera.classes: camera_name = node.name focal_length = node.fov - + creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, + "imageFormat": creator_attrs["imageFormat"], + "keepImages": creator_attrs["keepImages"], + "percentSize": creator_attrs["percentSize"], + "visualStyleMode": creator_attrs["visualStyleMode"], + "viewportPreset": creator_attrs["viewportPreset"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 8e06e52b5c..64ecbe5d85 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -1,8 +1,12 @@ import os +import contextlib import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import viewport_camera, get_max_version +from openpype.hosts.max.api.lib import ( + viewport_camera, + get_max_version +) class ExtractReviewAnimation(publish.Extractor): @@ -32,10 +36,24 @@ class ExtractReviewAnimation(publish.Extractor): " '%s' to '%s'" % (filename, staging_dir)) review_camera = instance.data["review_camera"] - with viewport_camera(review_camera): - preview_arg = self.set_preview_arg( - instance, filepath, start, end, fps) - rt.execute(preview_arg) + if get_max_version() >= 2024: + with viewport_camera(review_camera): + preview_arg = self.set_preview_arg( + instance, filepath, start, end, fps) + rt.execute(preview_arg) + else: + visual_style_preset = instance.data.get("visualStyleMode") + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + with viewport_camera(review_camera) and ( + self._visual_style_option( + viewport_setting, visual_style_preset) + ): + viewport_setting.VisualStyleMode = rt.Name( + visual_style_preset) + preview_arg = self.set_preview_arg( + instance, filepath, start, end, fps) + rt.execute(preview_arg) tags = ["review"] if not instance.data.get("keepImages"): @@ -76,10 +94,6 @@ class ExtractReviewAnimation(publish.Extractor): job_args.append(default_option) frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa job_args.append(frame_option) - rndLevel = instance.data.get("rndLevel") - if rndLevel: - option = f"rndLevel:#{rndLevel}" - job_args.append(option) options = [ "percentSize", "dspGeometry", "dspShapes", "dspLights", "dspCameras", "dspHelpers", "dspParticles", @@ -90,13 +104,36 @@ class ExtractReviewAnimation(publish.Extractor): enabled = instance.data.get(key) if enabled: job_args.append(f"{key}:{enabled}") - - if get_max_version() == 2024: - # hardcoded for current stage - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) + if get_max_version() >= 2024: + visual_style_preset = instance.data.get("visualStyleMode") + if visual_style_preset == "Realistic": + visual_style_preset = "defaultshading" + else: + visual_style_preset = visual_style_preset.lower() + # new argument exposed for Max 2024 for visual style + visual_style_option = f"vpStyle:#{visual_style_preset}" + job_args.append(visual_style_option) job_str = " ".join(job_args) self.log.debug(job_str) return job_str + + @contextlib.contextmanager + def _visual_style_option(self, viewport_setting, visual_style): + """Function to set visual style options + + Args: + visual_style (str): visual style for active viewport + + Returns: + list: the argument which can set visual style + """ + current_setting = viewport_setting.VisualStyleMode + if visual_style != current_setting: + try: + viewport_setting.VisualStyleMode = rt.Name( + visual_style) + yield + finally: + viewport_setting.VisualStyleMode = current_setting diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 82f4fc7a8b..4f9f3de6ab 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -81,9 +81,14 @@ class ExtractThumbnail(publish.Extractor): if enabled: job_args.append(f"{key}:{enabled}") if get_max_version() == 2024: - # hardcoded for current stage - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) + visual_style_preset = instance.data.get("visualStyleMode") + if visual_style_preset == "Realistic": + visual_style_preset = "defaultshading" + else: + visual_style_preset = visual_style_preset.lower() + # new argument exposed for Max 2024 for visual style + visual_style_option = f"vpStyle:#{visual_style_preset}" + job_args.append(visual_style_option) job_str = " ".join(job_args) self.log.debug(job_str) From d27d3435d97681fb7fd9b7ea72f3b8ed4700996a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 10:20:18 +0100 Subject: [PATCH 0518/1224] Use the right class for the exception --- .../plugins/publish/validate_sequence_frames.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index e24729391f..1f7753db37 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -3,6 +3,7 @@ import os import re import pyblish.api +from openpype.pipeline.publish import PublishValidationError class ValidateSequenceFrames(pyblish.api.InstancePlugin): @@ -40,15 +41,15 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): repr["files"], minimum_items=1, patterns=patterns) if remainder: - raise ValueError( + raise PublishValidationError( "Some files have been found outside a sequence. " f"Invalid files: {remainder}") if not collections: - raise ValueError( + raise PublishValidationError( "No collections found. There should be a single " "collection per representation.") if len(collections) > 1: - raise ValueError( + raise PublishValidationError( "Multiple collections detected. There should be a single " "collection per representation. " f"Collections identified: {collections}") @@ -65,11 +66,11 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): data["clipOut"]) if current_range != required_range: - raise ValueError(f"Invalid frame range: {current_range} - " + raise PublishValidationError(f"Invalid frame range: {current_range} - " f"expected: {required_range}") missing = collection.holes().indexes if missing: - raise ValueError( + raise PublishValidationError( "Missing frames have been detected. " f"Missing frames: {missing}") From ca07d562552f81ecbfae9d0582f145bcc6d7e182 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 10:20:43 +0100 Subject: [PATCH 0519/1224] Changed error message for not finding any collection --- .../unreal/plugins/publish/validate_sequence_frames.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 1f7753db37..334baf0cee 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -46,8 +46,10 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): f"Invalid files: {remainder}") if not collections: raise PublishValidationError( - "No collections found. There should be a single " - "collection per representation.") + "We have been unable to find a sequence in the " + "files. Please ensure the files are named " + "appropriately. " + f"Files: {repr_files}") if len(collections) > 1: raise PublishValidationError( "Multiple collections detected. There should be a single " From 19b58d8095e26e985a922a441fd654fbab301784 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 10:24:55 +0100 Subject: [PATCH 0520/1224] Hound fixes --- .../hosts/unreal/plugins/publish/validate_sequence_frames.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 334baf0cee..06acbf0992 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -68,8 +68,9 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): data["clipOut"]) if current_range != required_range: - raise PublishValidationError(f"Invalid frame range: {current_range} - " - f"expected: {required_range}") + raise PublishValidationError( + f"Invalid frame range: {current_range} - " + f"expected: {required_range}") missing = collection.holes().indexes if missing: From c389ccb5875353d32c3e535880108c0a648cd060 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 13:18:47 +0300 Subject: [PATCH 0521/1224] make description not instance specific --- .../hosts/houdini/plugins/publish/validate_frame_range.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 936eb1180d..2411d29e3e 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -23,7 +23,6 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: - node = invalid[0].path() raise PublishValidationError( title="Invalid Frame Range", message=( @@ -41,8 +40,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): "is set higher than the end frame.\n\nIf your ROP frame " "range is correct and you do not want to apply asset " "handles make sure to disable Use asset handles on the " - "publish instance.\n\nAssociated Node: \"{0}\"" - .format(node) + "publish instance." ) ) From 3314605db8447d5e92b3e31735cba80de179241e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 11 Oct 2023 18:59:07 +0800 Subject: [PATCH 0522/1224] move the preview arguments into the lib.py --- openpype/hosts/max/api/lib.py | 54 +++++++++++++++++++ .../hosts/max/plugins/create/create_review.py | 5 -- .../publish/extract_review_animation.py | 41 ++------------ .../max/plugins/publish/extract_thumbnail.py | 45 +++------------- 4 files changed, 66 insertions(+), 79 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 6f41cf9260..1ca2da81f8 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -500,3 +500,57 @@ def get_plugins() -> list: plugin_info_list.append(plugin_info) return plugin_info_list + +def set_preview_arg(instance, filepath, + start, end, fps): + """Function to set up preview arguments in MaxScript. + + Args: + instance (str): instance + filepath (str): output of the preview animation + start (int): startFrame + end (int): endFrame + fps (float): fps value + + Returns: + list: job arguments + """ + job_args = list() + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa + job_args.append(frame_option) + options = [ + "percentSize", "dspGeometry", "dspShapes", + "dspLights", "dspCameras", "dspHelpers", "dspParticles", + "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" + ] + + for key in options: + enabled = instance.data.get(key) + if enabled: + job_args.append(f"{key}:{enabled}") + if get_max_version() >= 2024: + visual_style_preset = instance.data.get("visualStyleMode") + if visual_style_preset == "Realistic": + visual_style_preset = "defaultshading" + else: + visual_style_preset = visual_style_preset.lower() + # new argument exposed for Max 2024 for visual style + visual_style_option = f"vpStyle:#{visual_style_preset}" + job_args.append(visual_style_option) + # new argument for pre-view preset exposed in Max 2024 + preview_preset = instance.data.get("viewportPreset") + if preview_preset == "Quality": + preview_preset = "highquality" + elif preview_preset == "Customize": + preview_preset = "userdefined" + else: + preview_preset = preview_preset.lower() + preview_preset.option = f"vpPreset:#{visual_style_preset}" + job_args.append(preview_preset) + + job_str = " ".join(job_args) + log.debug(job_str) + + return job_str diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 5638327d72..e654783a33 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -13,11 +13,6 @@ class CreateReview(plugin.MaxCreator): icon = "video-camera" def create(self, subset_name, instance_data, pre_create_data): - - instance_data["imageFormat"] = pre_create_data.get("imageFormat") - instance_data["keepImages"] = pre_create_data.get("keepImages") - instance_data["percentSize"] = pre_create_data.get("percentSize") - instance_data["visualStyleMode"] = pre_create_data.get("visualStyleMode") # Transfer settings from pre create to instance creator_attributes = instance_data.setdefault( "creator_attributes", dict()) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 64ecbe5d85..9c26ef7e7d 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -5,7 +5,8 @@ from pymxs import runtime as rt from openpype.pipeline import publish from openpype.hosts.max.api.lib import ( viewport_camera, - get_max_version + get_max_version, + set_preview_arg ) @@ -25,7 +26,7 @@ class ExtractReviewAnimation(publish.Extractor): filename = "{0}..{1}".format(instance.name, ext) start = int(instance.data["frameStart"]) end = int(instance.data["frameEnd"]) - fps = int(instance.data["fps"]) + fps = float(instance.data["fps"]) filepath = os.path.join(staging_dir, filename) filepath = filepath.replace("\\", "/") filenames = self.get_files( @@ -38,7 +39,7 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] if get_max_version() >= 2024: with viewport_camera(review_camera): - preview_arg = self.set_preview_arg( + preview_arg = set_preview_arg( instance, filepath, start, end, fps) rt.execute(preview_arg) else: @@ -51,7 +52,7 @@ class ExtractReviewAnimation(publish.Extractor): ): viewport_setting.VisualStyleMode = rt.Name( visual_style_preset) - preview_arg = self.set_preview_arg( + preview_arg = set_preview_arg( instance, filepath, start, end, fps) rt.execute(preview_arg) @@ -87,38 +88,6 @@ class ExtractReviewAnimation(publish.Extractor): return file_list - def set_preview_arg(self, instance, filepath, - start, end, fps): - job_args = list() - default_option = f'CreatePreview filename:"{filepath}"' - job_args.append(default_option) - frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa - job_args.append(frame_option) - options = [ - "percentSize", "dspGeometry", "dspShapes", - "dspLights", "dspCameras", "dspHelpers", "dspParticles", - "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" - ] - - for key in options: - enabled = instance.data.get(key) - if enabled: - job_args.append(f"{key}:{enabled}") - if get_max_version() >= 2024: - visual_style_preset = instance.data.get("visualStyleMode") - if visual_style_preset == "Realistic": - visual_style_preset = "defaultshading" - else: - visual_style_preset = visual_style_preset.lower() - # new argument exposed for Max 2024 for visual style - visual_style_option = f"vpStyle:#{visual_style_preset}" - job_args.append(visual_style_option) - - job_str = " ".join(job_args) - self.log.debug(job_str) - - return job_str - @contextlib.contextmanager def _visual_style_option(self, viewport_setting, visual_style): """Function to set visual style options diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 4f9f3de6ab..22c45f3e11 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -3,7 +3,11 @@ import tempfile import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import viewport_camera, get_max_version +from openpype.hosts.max.api.lib import ( + viewport_camera, + get_max_version, + set_preview_arg +) class ExtractThumbnail(publish.Extractor): @@ -23,7 +27,7 @@ class ExtractThumbnail(publish.Extractor): self.log.debug( f"Create temp directory {tmp_staging} for thumbnail" ) - fps = int(instance.data["fps"]) + fps = float(instance.data["fps"]) frame = int(instance.data["frameStart"]) instance.context.data["cleanupFullPaths"].append(tmp_staging) filename = "{name}_thumbnail..png".format(**instance.data) @@ -36,7 +40,7 @@ class ExtractThumbnail(publish.Extractor): " '%s' to '%s'" % (filename, tmp_staging)) review_camera = instance.data["review_camera"] with viewport_camera(review_camera): - preview_arg = self.set_preview_arg( + preview_arg = set_preview_arg( instance, filepath, fps, frame) rt.execute(preview_arg) @@ -59,38 +63,3 @@ class ExtractThumbnail(publish.Extractor): filename, target_frame ) return thumbnail_name - - def set_preview_arg(self, instance, filepath, fps, frame): - job_args = list() - default_option = f'CreatePreview filename:"{filepath}"' - job_args.append(default_option) - frame_option = f"outputAVI:false start:{frame} end:{frame} fps:{fps}" # noqa - job_args.append(frame_option) - rndLevel = instance.data.get("rndLevel") - if rndLevel: - option = f"rndLevel:#{rndLevel}" - job_args.append(option) - options = [ - "percentSize", "dspGeometry", "dspShapes", - "dspLights", "dspCameras", "dspHelpers", "dspParticles", - "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" - ] - - for key in options: - enabled = instance.data.get(key) - if enabled: - job_args.append(f"{key}:{enabled}") - if get_max_version() == 2024: - visual_style_preset = instance.data.get("visualStyleMode") - if visual_style_preset == "Realistic": - visual_style_preset = "defaultshading" - else: - visual_style_preset = visual_style_preset.lower() - # new argument exposed for Max 2024 for visual style - visual_style_option = f"vpStyle:#{visual_style_preset}" - job_args.append(visual_style_option) - - job_str = " ".join(job_args) - self.log.debug(job_str) - - return job_str From 7e5ce50fe1536675bdb35b5de43cb4c331007495 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 11 Oct 2023 19:00:29 +0800 Subject: [PATCH 0523/1224] hound --- openpype/hosts/max/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 1ca2da81f8..68baf720bc 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -501,6 +501,7 @@ def get_plugins() -> list: return plugin_info_list + def set_preview_arg(instance, filepath, start, end, fps): """Function to set up preview arguments in MaxScript. From 63828671745691d7463cb49740aaae6846f524b7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Oct 2023 13:29:56 +0200 Subject: [PATCH 0524/1224] :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 91c37916cb82f75515f81a54b922769dab5b1816 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 15:23:26 +0200 Subject: [PATCH 0525/1224] improving variable names and fixing nodes overrides - also bumping vrsion and fixing label for input process node --- openpype/settings/ayon_settings.py | 31 ++++++- server_addon/nuke/server/settings/imageio.py | 90 +++++++++++-------- .../nuke/server/settings/publish_plugins.py | 20 +++-- server_addon/nuke/server/version.py | 2 +- 4 files changed, 95 insertions(+), 48 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index d54d71e851..0b3f6725d8 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -819,9 +819,36 @@ def _convert_nuke_project_settings(ayon_settings, output): # NOTE 'monitorOutLut' is maybe not yet in v3 (ut should be) _convert_host_imageio(ayon_nuke) ayon_imageio = ayon_nuke["imageio"] - for item in ayon_imageio["nodes"]["requiredNodes"]: + + # workfile + imageio_workfile = ayon_imageio["workfile"] + workfile_keys_mapping = ( + ("color_management", "colorManagement"), + ("native_ocio_config", "OCIO_config"), + ("working_space", "workingSpaceLUT"), + ("thumbnail_space", "monitorLut"), + ) + for src, dst in workfile_keys_mapping: + if ( + src in imageio_workfile + and dst not in imageio_workfile + ): + imageio_workfile[dst] = imageio_workfile.pop(src) + + # regex inputs + regex_inputs = ayon_imageio.get("regex_inputs") + if regex_inputs: + ayon_imageio.pop("regex_inputs") + ayon_imageio["regexInputs"] = regex_inputs + + # nodes + for item in ayon_imageio["nodes"]["required_nodes"]: + if item.get("nuke_node_class"): + item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) - for item in ayon_imageio["nodes"]["overrideNodes"]: + for item in ayon_imageio["nodes"]["override_nodes"]: + if item.get("nuke_node_class"): + item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) output["nuke"] = ayon_nuke diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index 811b12104b..15ccd4e89a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -14,10 +14,30 @@ class NodesModel(BaseSettingsModel): default_factory=list, title="Used in plugins" ) - nukeNodeClass: str = Field( + nuke_node_class: str = Field( title="Nuke Node Class", ) + +class RequiredNodesModel(NodesModel): + knobs: list[KnobModel] = Field( + default_factory=list, + title="Knobs", + ) + + @validator("knobs") + def ensure_unique_names(cls, value): + """Ensure name fields within the lists have unique names.""" + ensure_unique_names(value) + return value + + +class OverrideNodesModel(NodesModel): + subsets: list[str] = Field( + default_factory=list, + title="Subsets" + ) + knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -31,13 +51,11 @@ class NodesModel(BaseSettingsModel): class NodesSetting(BaseSettingsModel): - # TODO: rename `requiredNodes` to `required_nodes` - requiredNodes: list[NodesModel] = Field( + required_nodes: list[RequiredNodesModel] = Field( title="Plugin required", default_factory=list ) - # TODO: rename `overrideNodes` to `override_nodes` - overrideNodes: list[NodesModel] = Field( + override_nodes: list[OverrideNodesModel] = Field( title="Plugin's node overrides", default_factory=list ) @@ -46,38 +64,40 @@ class NodesSetting(BaseSettingsModel): def ocio_configs_switcher_enum(): return [ {"value": "nuke-default", "label": "nuke-default"}, - {"value": "spi-vfx", "label": "spi-vfx"}, - {"value": "spi-anim", "label": "spi-anim"}, - {"value": "aces_0.1.1", "label": "aces_0.1.1"}, - {"value": "aces_0.7.1", "label": "aces_0.7.1"}, - {"value": "aces_1.0.1", "label": "aces_1.0.1"}, - {"value": "aces_1.0.3", "label": "aces_1.0.3"}, - {"value": "aces_1.1", "label": "aces_1.1"}, - {"value": "aces_1.2", "label": "aces_1.2"}, - {"value": "aces_1.3", "label": "aces_1.3"}, - {"value": "custom", "label": "custom"} + {"value": "spi-vfx", "label": "spi-vfx (11)"}, + {"value": "spi-anim", "label": "spi-anim (11)"}, + {"value": "aces_0.1.1", "label": "aces_0.1.1 (11)"}, + {"value": "aces_0.7.1", "label": "aces_0.7.1 (11)"}, + {"value": "aces_1.0.1", "label": "aces_1.0.1 (11)"}, + {"value": "aces_1.0.3", "label": "aces_1.0.3 (11, 12)"}, + {"value": "aces_1.1", "label": "aces_1.1 (12, 13)"}, + {"value": "aces_1.2", "label": "aces_1.2 (13, 14)"}, + {"value": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1", + "label": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"}, + {"value": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1", + "label": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"}, ] class WorkfileColorspaceSettings(BaseSettingsModel): """Nuke workfile colorspace preset. """ - colorManagement: Literal["Nuke", "OCIO"] = Field( - title="Color Management" + color_management: Literal["Nuke", "OCIO"] = Field( + title="Color Management Workflow" ) - OCIO_config: str = Field( - title="OpenColorIO Config", - description="Switch between OCIO configs", + native_ocio_config: str = Field( + title="Native OpenColorIO Config", + description="Switch between native OCIO configs", enum_resolver=ocio_configs_switcher_enum, conditionalEnum=True ) - workingSpaceLUT: str = Field( + working_space: str = Field( title="Working Space" ) - monitorLut: str = Field( - title="Monitor" + thumbnail_space: str = Field( + title="Thumbnail Space" ) @@ -182,12 +202,10 @@ class ImageIOSettings(BaseSettingsModel): title="Nodes" ) """# TODO: enhance settings with host api: - - [ ] old settings are using `regexInputs` key but we - need to rename to `regex_inputs` - [ ] no need for `inputs` middle part. It can stay directly on `regex_inputs` """ - regexInputs: RegexInputsModel = Field( + regex_inputs: RegexInputsModel = Field( default_factory=RegexInputsModel, title="Assign colorspace to read nodes via rules" ) @@ -201,18 +219,18 @@ DEFAULT_IMAGEIO_SETTINGS = { "viewerProcess": "rec709" }, "workfile": { - "colorManagement": "Nuke", - "OCIO_config": "nuke-default", - "workingSpaceLUT": "linear", - "monitorLut": "sRGB", + "color_management": "Nuke", + "native_ocio_config": "nuke-default", + "working_space": "linear", + "thumbnail_space": "sRGB", }, "nodes": { - "requiredNodes": [ + "required_nodes": [ { "plugins": [ "CreateWriteRender" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -264,7 +282,7 @@ DEFAULT_IMAGEIO_SETTINGS = { "plugins": [ "CreateWritePrerender" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -316,7 +334,7 @@ DEFAULT_IMAGEIO_SETTINGS = { "plugins": [ "CreateWriteImage" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -360,9 +378,9 @@ DEFAULT_IMAGEIO_SETTINGS = { ] } ], - "overrideNodes": [] + "override_nodes": [] }, - "regexInputs": { + "regex_inputs": { "inputs": [ { "regex": "(beauty).*(?=.exr)", diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 19206149b6..2d3a282c46 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -155,8 +155,10 @@ class IntermediateOutputModel(BaseSettingsModel): title="Filter", default_factory=BakingStreamFilterModel) read_raw: bool = Field(title="Read raw switch") viewer_process_override: str = Field(title="Viewer process override") - bake_viewer_process: bool = Field(title="Bake view process") - bake_viewer_input_process: bool = Field(title="Bake viewer input process") + bake_viewer_process: bool = Field(title="Bake viewer process") + bake_viewer_input_process: bool = Field( + title="Bake viewer input process node (LUT)" + ) reformat_nodes_config: ReformatNodesConfigModel = Field( default_factory=ReformatNodesConfigModel, title="Reformat Nodes") @@ -407,12 +409,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "text": "Lanczos6" }, { - "type": "bool", + "type": "boolean", "name": "black_outside", "boolean": True }, { - "type": "bool", + "type": "boolean", "name": "pbb", "boolean": False } @@ -427,7 +429,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "enabled": False }, "ExtractReviewDataMov": { - "enabled": True, + "enabled": False, "viewer_lut_raw": False, "outputs": [ { @@ -463,12 +465,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "text": "Lanczos6" }, { - "type": "bool", + "type": "boolean", "name": "black_outside", "boolean": True }, { - "type": "bool", + "type": "boolean", "name": "pbb", "boolean": False } @@ -518,12 +520,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "text": "Lanczos6" }, { - "type": "bool", + "type": "boolean", "name": "black_outside", "boolean": True }, { - "type": "bool", + "type": "boolean", "name": "pbb", "boolean": False } diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" From 47a02c2386f79bbbd63ac7328154bd1943499849 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:34:10 +0100 Subject: [PATCH 0526/1224] Change pointcache creator to be in line with other creators --- .../plugins/create/create_pointcache.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 6220f68dc5..65cf18472d 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -3,11 +3,11 @@ import bpy from openpype.pipeline import get_current_task_name -import openpype.hosts.blender.api.plugin -from openpype.hosts.blender.api import lib +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): +class CreatePointcache(plugin.Creator): """Polygonal static geometry""" name = "pointcacheMain" @@ -16,20 +16,36 @@ class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): icon = "gears" 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 = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) self.data['task'] = get_current_task_name() - lib.imprint(collection, self.data) + lib.imprint(asset_group, self.data) + # Add selected objects to instance if (self.options or {}).get("useSelection"): - objects = lib.get_selection() - for obj in objects: - collection.objects.link(obj) - if obj.type == 'EMPTY': - objects.extend(obj.children) + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + if obj.parent in selected: + obj.select_set(False) + continue + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) - return collection + return asset_group From 7fec582a2d472c8a956621a53d556a8ee784e52a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:34:55 +0100 Subject: [PATCH 0527/1224] Improve instance collector --- .../plugins/publish/collect_instances.py | 101 +++++++----------- 1 file changed, 40 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index bc4b5ab092..4915e4a7cf 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,4 +1,5 @@ import json +from itertools import chain from typing import Generator import bpy @@ -19,85 +20,63 @@ class CollectInstances(pyblish.api.ContextPlugin): @staticmethod def get_asset_groups() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. + """Return all instances that are empty objects asset groups. """ instances = bpy.data.collections.get(AVALON_INSTANCES) for obj in instances.objects: - avalon_prop = obj.get(AVALON_PROPERTY) or dict() + avalon_prop = obj.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield obj @staticmethod def get_collections() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. + """Return all instances that are collections. """ - for collection in bpy.data.collections: - avalon_prop = collection.get(AVALON_PROPERTY) or dict() + instances = bpy.data.collections.get(AVALON_INSTANCES) + for collection in instances.children: + avalon_prop = collection.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield collection + @staticmethod + def create_instance(context, group): + avalon_prop = group[AVALON_PROPERTY] + asset = avalon_prop['asset'] + family = avalon_prop['family'] + subset = avalon_prop['subset'] + task = avalon_prop['task'] + name = f"{asset}_{subset}" + return context.create_instance( + name=name, + family=family, + families=[family], + subset=subset, + asset=asset, + task=task, + ), family + def process(self, context): """Collect the models from the current Blender scene.""" asset_groups = self.get_asset_groups() collections = self.get_collections() - for group in asset_groups: - avalon_prop = group[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - objects = list(group.children) - members = set() - for obj in objects: - objects.extend(list(obj.children)) - members.add(obj) - members.add(group) - instance[:] = list(members) - self.log.debug(json.dumps(instance.data, indent=4)) - for obj in instance: - self.log.debug(obj) + instances = chain(asset_groups, collections) - for collection in collections: - avalon_prop = collection[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - members = list(collection.objects) - if family == "animation": - for obj in collection.objects: - if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): - for child in obj.children: - if child.type == 'ARMATURE': - members.append(child) - members.append(collection) + for group in instances: + instance, family = self.create_instance(context, group) + members = [] + if type(group) == bpy.types.Collection: + members = list(group.objects) + if family == "animation": + for obj in group.objects: + if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): + members.extend( + child for child in obj.children + if child.type == 'ARMATURE') + else: + members = group.children_recursive + + members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) for obj in instance: From 9f82f8ee2ff35aa66f0c3447a8aefb0adff101c6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:38:23 +0100 Subject: [PATCH 0528/1224] Changed how alembic files are extracted --- .../plugins/publish/collect_instances.py | 3 +++ .../blender/plugins/publish/extract_abc.py | 3 +-- .../plugins/publish/extract_abc_model.py | 17 +++++++++++++++++ .../defaults/project_settings/blender.json | 2 +- .../schemas/schema_blender_publish.json | 8 ++++---- .../blender/server/settings/publish_plugins.py | 4 ++-- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/extract_abc_model.py diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 4915e4a7cf..1e0db9d9ce 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -76,6 +76,9 @@ class CollectInstances(pyblish.api.ContextPlugin): else: members = group.children_recursive + if family == "pointcache": + instance.data["families"].append("abc.export") + members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 87159e53f0..b113685842 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -12,8 +12,7 @@ class ExtractABC(publish.Extractor): label = "Extract ABC" hosts = ["blender"] - families = ["model", "pointcache"] - optional = True + families = ["abc.export"] def process(self, instance): # Define extract output file path diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_model.py b/openpype/hosts/blender/plugins/publish/extract_abc_model.py new file mode 100644 index 0000000000..b31e36c681 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_abc_model.py @@ -0,0 +1,17 @@ +import pyblish.api +from openpype.pipeline import publish + + +class ExtractModelABC(publish.Extractor): + """Extract model as ABC.""" + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Model ABC" + hosts = ["blender"] + families = ["model"] + optional = True + + def process(self, instance): + # Add abc.export family to the instance, to allow the extraction + # as alembic of the asset. + instance.data["families"].append("abc.export") diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index f3eb31174f..2bc518e329 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -89,7 +89,7 @@ "optional": true, "active": false }, - "ExtractABC": { + "ExtractModelABC": { "enabled": true, "optional": true, "active": false 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 7f1a8a915b..b84c663e6c 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 @@ -181,12 +181,12 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ExtractFBX", - "label": "Extract FBX (model and rig)" + "key": "ExtractModelABC", + "label": "Extract ABC (model)" }, { - "key": "ExtractABC", - "label": "Extract ABC (model and pointcache)" + "key": "ExtractFBX", + "label": "Extract FBX (model and rig)" }, { "key": "ExtractBlendAnimation", diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 5e047b7013..102320cfed 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -103,7 +103,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Extract FBX" ) - ExtractABC: ValidatePluginModel = Field( + ExtractModelABC: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Extract ABC" ) @@ -197,7 +197,7 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": False }, - "ExtractABC": { + "ExtractModelABC": { "enabled": True, "optional": True, "active": False From 08e10fd59af02725011886e0e133fc0a70b084d1 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:43:38 +0100 Subject: [PATCH 0529/1224] Make extraction of models as alembic on by default --- openpype/settings/defaults/project_settings/blender.json | 2 +- server_addon/blender/server/settings/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 2bc518e329..7fb8c333a6 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -92,7 +92,7 @@ "ExtractModelABC": { "enabled": true, "optional": true, - "active": false + "active": true }, "ExtractBlendAnimation": { "enabled": true, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 102320cfed..27dc0b232f 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -200,7 +200,7 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "ExtractModelABC": { "enabled": True, "optional": True, - "active": False + "active": True }, "ExtractBlendAnimation": { "enabled": True, From 23b29c947cb59fc626047846d86cf5d714d515f5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 15:17:33 +0100 Subject: [PATCH 0530/1224] Improved function to create the instance --- openpype/hosts/blender/plugins/publish/collect_instances.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 1e0db9d9ce..cc163fc97e 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -53,7 +53,7 @@ class CollectInstances(pyblish.api.ContextPlugin): subset=subset, asset=asset, task=task, - ), family + ) def process(self, context): """Collect the models from the current Blender scene.""" @@ -63,7 +63,8 @@ class CollectInstances(pyblish.api.ContextPlugin): instances = chain(asset_groups, collections) for group in instances: - instance, family = self.create_instance(context, group) + instance = self.create_instance(context, group) + family = instance.data["family"] members = [] if type(group) == bpy.types.Collection: members = list(group.objects) From 70abe6b7b7576699918ef6cb818c019b888567bf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 15:21:44 +0100 Subject: [PATCH 0531/1224] Merged the two functions to get asset groups --- .../plugins/publish/collect_instances.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index cc163fc97e..b4fc167638 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,5 +1,4 @@ import json -from itertools import chain from typing import Generator import bpy @@ -23,21 +22,11 @@ class CollectInstances(pyblish.api.ContextPlugin): """Return all instances that are empty objects asset groups. """ instances = bpy.data.collections.get(AVALON_INSTANCES) - for obj in instances.objects: + for obj in list(instances.objects) + list(instances.children): avalon_prop = obj.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield obj - @staticmethod - def get_collections() -> Generator: - """Return all instances that are collections. - """ - instances = bpy.data.collections.get(AVALON_INSTANCES) - for collection in instances.children: - avalon_prop = collection.get(AVALON_PROPERTY) or {} - if avalon_prop.get('id') == 'pyblish.avalon.instance': - yield collection - @staticmethod def create_instance(context, group): avalon_prop = group[AVALON_PROPERTY] @@ -58,11 +47,8 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): """Collect the models from the current Blender scene.""" asset_groups = self.get_asset_groups() - collections = self.get_collections() - instances = chain(asset_groups, collections) - - for group in instances: + for group in asset_groups: instance = self.create_instance(context, group) family = instance.data["family"] members = [] From 0ba3e00abc688d8b5604e983feb10c21fb9fc346 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 16:28:33 +0200 Subject: [PATCH 0532/1224] fixing missing `overrideNodes` and `requiredNodes` --- openpype/settings/ayon_settings.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 0b3f6725d8..db3624b2d0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -842,11 +842,20 @@ def _convert_nuke_project_settings(ayon_settings, output): ayon_imageio["regexInputs"] = regex_inputs # nodes - for item in ayon_imageio["nodes"]["required_nodes"]: + ayon_imageio_nodes = ayon_imageio["nodes"] + if ayon_imageio_nodes.get("required_nodes"): + ayon_imageio_nodes["requiredNodes"] = ( + ayon_imageio_nodes.pop("required_nodes")) + if ayon_imageio_nodes.get("override_nodes"): + ayon_imageio_nodes["overrideNodes"] = ( + ayon_imageio_nodes.pop("override_nodes")) + + for item in ayon_imageio_nodes["requiredNodes"]: if item.get("nuke_node_class"): item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) - for item in ayon_imageio["nodes"]["override_nodes"]: + + for item in ayon_imageio["nodes"]["overrideNodes"]: if item.get("nuke_node_class"): item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) From bdf86cffec47e08ab747799a4ae275562b0e01d3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 16:30:54 +0200 Subject: [PATCH 0533/1224] tunnig previous commit --- openpype/settings/ayon_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index db3624b2d0..f8ab067fca 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -855,7 +855,7 @@ def _convert_nuke_project_settings(ayon_settings, output): item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) - for item in ayon_imageio["nodes"]["overrideNodes"]: + for item in ayon_imageio_nodes["overrideNodes"]: if item.get("nuke_node_class"): item["nukeNodeClass"] = item["nuke_node_class"] item["knobs"] = _convert_nuke_knobs(item["knobs"]) 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 0534/1224] 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 0535/1224] 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 0536/1224] 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 0537/1224] 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 45b61c21711b92c5a59e73b1125c2da2696d62de Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 23:12:42 +0300 Subject: [PATCH 0538/1224] update create and publish plugins --- .../houdini/server/settings/create.py | 146 +++++++++++------- .../houdini/server/settings/publish.py | 68 ++++---- 2 files changed, 124 insertions(+), 90 deletions(-) diff --git a/server_addon/houdini/server/settings/create.py b/server_addon/houdini/server/settings/create.py index a1f8d24c30..81b871e83f 100644 --- a/server_addon/houdini/server/settings/create.py +++ b/server_addon/houdini/server/settings/create.py @@ -34,52 +34,110 @@ class CreateStaticMeshModel(BaseSettingsModel): class CreatePluginsModel(BaseSettingsModel): - CreateArnoldAss: CreateArnoldAssModel = Field( - default_factory=CreateArnoldAssModel, - title="Create Arnold Ass") - # "-" is not compatible in the new model - CreateStaticMesh: CreateStaticMeshModel = Field( - default_factory=CreateStaticMeshModel, - title="Create Static Mesh" - ) CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, title="Create Alembic Camera") + CreateArnoldAss: CreateArnoldAssModel = Field( + default_factory=CreateArnoldAssModel, + title="Create Arnold Ass") + CreateArnoldRop: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Arnold ROP") CreateCompositeSequence: CreatorModel = Field( default_factory=CreatorModel, - title="Create Composite Sequence") + title="Create Composite (Image Sequence)") + CreateHDA: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Houdini Digital Asset") + CreateKarmaROP: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Karma ROP") + CreateMantraROP: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Mantra ROP") CreatePointCache: CreatorModel = Field( default_factory=CreatorModel, - title="Create Point Cache") + title="Create PointCache (Abc)") + CreateBGEO: CreatorModel = Field( + default_factory=CreatorModel, + title="Create PointCache (Bgeo)") + CreateRedshiftProxy: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Redshift Proxy") CreateRedshiftROP: CreatorModel = Field( default_factory=CreatorModel, - title="Create RedshiftROP") - CreateRemotePublish: CreatorModel = Field( + title="Create Redshift ROP") + CreateReview: CreatorModel = Field( default_factory=CreatorModel, - title="Create Remote Publish") + title="Create Review") + # "-" is not compatible in the new model + CreateStaticMesh: CreateStaticMeshModel = Field( + default_factory=CreateStaticMeshModel, + title="Create Static Mesh") + CreateUSD: CreatorModel = Field( + default_factory=CreatorModel, + title="Create USD (experimental)") + CreateUSDRender: CreatorModel = Field( + default_factory=CreatorModel, + title="Create USD render (experimental)") CreateVDBCache: CreatorModel = Field( default_factory=CreatorModel, title="Create VDB Cache") - CreateUSD: CreatorModel = Field( + CreateVrayROP: CreatorModel = Field( default_factory=CreatorModel, - title="Create USD") - CreateUSDModel: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD model") - USDCreateShadingWorkspace: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD shading workspace") - CreateUSDRender: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD render") + title="Create VRay ROP") DEFAULT_HOUDINI_CREATE_SETTINGS = { + "CreateAlembicCamera": { + "enabled": True, + "default_variants": ["Main"] + }, "CreateArnoldAss": { "enabled": True, "default_variants": ["Main"], "ext": ".ass" }, + "CreateArnoldRop": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateCompositeSequence": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateHDA": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateKarmaROP": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateMantraROP": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreatePointCache": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateBGEO": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateRedshiftProxy": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateRedshiftROP": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateReview": { + "enabled": True, + "default_variants": ["Main"] + }, "CreateStaticMesh": { "enabled": True, "default_variants": [ @@ -93,44 +151,20 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "UCX" ] }, - "CreateAlembicCamera": { - "enabled": True, - "default_variants": ["Main"] - }, - "CreateCompositeSequence": { - "enabled": True, - "default_variants": ["Main"] - }, - "CreatePointCache": { - "enabled": True, - "default_variants": ["Main"] - }, - "CreateRedshiftROP": { - "enabled": True, - "default_variants": ["Main"] - }, - "CreateRemotePublish": { - "enabled": True, - "default_variants": ["Main"] - }, - "CreateVDBCache": { - "enabled": True, - "default_variants": ["Main"] - }, "CreateUSD": { "enabled": False, "default_variants": ["Main"] }, - "CreateUSDModel": { - "enabled": False, - "default_variants": ["Main"] - }, - "USDCreateShadingWorkspace": { - "enabled": False, - "default_variants": ["Main"] - }, "CreateUSDRender": { "enabled": False, "default_variants": ["Main"] }, + "CreateVDBCache": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateVrayROP": { + "enabled": True, + "default_variants": ["Main"] + }, } diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 7612c446bf..5a1ee1fa07 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -23,27 +23,52 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): - ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( - default_factory=ValidateWorkfilePathsModel, - title="Validate workfile paths settings.") - ValidateReviewColorspace: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Review Colorspace.") ValidateContainers: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Latest Containers.") - ValidateSubsetName: BasicValidateModel = Field( - default_factory=BasicValidateModel, - title="Validate Subset Name.") ValidateMeshIsStatic: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Mesh is Static.") + ValidateReviewColorspace: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Review Colorspace.") + ValidateSubsetName: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Subset Name.") ValidateUnrealStaticMeshName: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Unreal Static Mesh Name.") + ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( + default_factory=ValidateWorkfilePathsModel, + title="Validate workfile paths settings.") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { + "ValidateContainers": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateMeshIsStatic": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateReviewColorspace": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSubsetName": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateUnrealStaticMeshName": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateWorkfilePaths": { "enabled": True, "optional": True, @@ -55,30 +80,5 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "$HIP", "$JOB" ] - }, - "ValidateReviewColorspace": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateContainers": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateSubsetName": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateMeshIsStatic": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateUnrealStaticMeshName": { - "enabled": False, - "optional": True, - "active": True } } From dc28a8d3d285b41556fa9185268e1c3df22b8f51 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 11 Oct 2023 22:28:31 +0200 Subject: [PATCH 0539/1224] 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 0540/1224] 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 0541/1224] 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 8ee57bd3a1e030f32f85b0c409e44d09e2e0c9bb Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 11 Oct 2023 23:57:42 +0300 Subject: [PATCH 0542/1224] bugfix update instance parameters values on update_instances --- openpype/hosts/houdini/api/lib.py | 1 - openpype/hosts/houdini/api/plugin.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 3b45914b19..6fa8b02735 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -932,7 +932,6 @@ def self_publish(): active = node_path in inputs_paths instance["active"] = active - hou.node(node_path).parm("active").set(active) context.save_changes() diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index c82ba11114..5102b64644 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -250,11 +250,14 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): key: changes[key].new_value for key in changes.changed_keys } + # Update ParmTemplates self.imprint( instance_node, new_values, update=True ) + # Update values + instance_node.setParms(new_values) def imprint(self, node, values, update=False): # Never store instance node and instance id since that data comes From 7035e1e0145f11f832eee08754f681e3304e591d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 12 Oct 2023 13:50:31 +0800 Subject: [PATCH 0543/1224] clean up the codes in thummbnail and preview animation extractor --- openpype/hosts/max/api/lib.py | 32 ++++++++++++++++++- .../publish/extract_review_animation.py | 32 ++++--------------- .../max/plugins/publish/extract_thumbnail.py | 26 ++++++++++++--- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 68baf720bc..fe2742cdb0 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -322,7 +322,7 @@ def is_headless(): @contextlib.contextmanager -def viewport_camera(camera): +def viewport_setup_updated(camera): original = rt.viewport.getCamera() has_autoplay = rt.preferences.playPreviewWhenDone if not original: @@ -339,6 +339,36 @@ def viewport_camera(camera): rt.preferences.playPreviewWhenDone = has_autoplay +@contextlib.contextmanager +def viewport_setup(viewport_setting, visual_style, camera): + """Function to set visual style options + + Args: + visual_style (str): visual style for active viewport + + Returns: + list: the argument which can set visual style + """ + original = rt.viewport.getCamera() + has_autoplay = rt.preferences.playPreviewWhenDone + if not original: + # if there is no original camera + # use the current camera as original + original = rt.getNodeByName(camera) + review_camera = rt.getNodeByName(camera) + current_setting = viewport_setting.VisualStyleMode + try: + rt.viewport.setCamera(review_camera) + viewport_setting.VisualStyleMode = rt.Name( + visual_style) + rt.preferences.playPreviewWhenDone = False + yield + finally: + rt.viewport.setCamera(original) + viewport_setting.VisualStyleMode = current_setting + rt.preferences.playPreviewWhenDone = has_autoplay + + def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 9c26ef7e7d..24e7785b2b 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -4,7 +4,8 @@ import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish from openpype.hosts.max.api.lib import ( - viewport_camera, + viewport_setup_updated, + viewport_setup, get_max_version, set_preview_arg ) @@ -38,7 +39,7 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] if get_max_version() >= 2024: - with viewport_camera(review_camera): + with viewport_setup_updated(review_camera): preview_arg = set_preview_arg( instance, filepath, start, end, fps) rt.execute(preview_arg) @@ -46,10 +47,10 @@ class ExtractReviewAnimation(publish.Extractor): visual_style_preset = instance.data.get("visualStyleMode") nitrousGraphicMgr = rt.NitrousGraphicsManager viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - with viewport_camera(review_camera) and ( - self._visual_style_option( - viewport_setting, visual_style_preset) - ): + with viewport_setup( + viewport_setting, + visual_style_preset, + review_camera): viewport_setting.VisualStyleMode = rt.Name( visual_style_preset) preview_arg = set_preview_arg( @@ -87,22 +88,3 @@ class ExtractReviewAnimation(publish.Extractor): file_list.append(actual_name) return file_list - - @contextlib.contextmanager - def _visual_style_option(self, viewport_setting, visual_style): - """Function to set visual style options - - Args: - visual_style (str): visual style for active viewport - - Returns: - list: the argument which can set visual style - """ - current_setting = viewport_setting.VisualStyleMode - if visual_style != current_setting: - try: - viewport_setting.VisualStyleMode = rt.Name( - visual_style) - yield - finally: - viewport_setting.VisualStyleMode = current_setting diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 22c45f3e11..731dac74e3 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -4,12 +4,14 @@ import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish from openpype.hosts.max.api.lib import ( - viewport_camera, + viewport_setup_updated, + viewport_setup, get_max_version, set_preview_arg ) + class ExtractThumbnail(publish.Extractor): """ Extract Thumbnail for Review @@ -39,10 +41,24 @@ class ExtractThumbnail(publish.Extractor): "Writing Thumbnail to" " '%s' to '%s'" % (filename, tmp_staging)) review_camera = instance.data["review_camera"] - with viewport_camera(review_camera): - preview_arg = set_preview_arg( - instance, filepath, fps, frame) - rt.execute(preview_arg) + if get_max_version() >= 2024: + with viewport_setup_updated(review_camera): + preview_arg = set_preview_arg( + instance, filepath, frame, frame, fps) + rt.execute(preview_arg) + else: + visual_style_preset = instance.data.get("visualStyleMode") + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + with viewport_setup( + viewport_setting, + visual_style_preset, + review_camera): + viewport_setting.VisualStyleMode = rt.Name( + visual_style_preset) + preview_arg = set_preview_arg( + instance, filepath, frame, frame, fps) + rt.execute(preview_arg) representation = { "name": "thumbnail", From 7d98ddfbe143fb92f64bbf4aea37bba937a7127d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 12 Oct 2023 13:52:04 +0800 Subject: [PATCH 0544/1224] hound --- .../hosts/max/plugins/publish/extract_review_animation.py | 7 +++---- openpype/hosts/max/plugins/publish/extract_thumbnail.py | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 24e7785b2b..acabd74958 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -1,5 +1,4 @@ import os -import contextlib import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish @@ -48,9 +47,9 @@ class ExtractReviewAnimation(publish.Extractor): nitrousGraphicMgr = rt.NitrousGraphicsManager viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() with viewport_setup( - viewport_setting, - visual_style_preset, - review_camera): + viewport_setting, + visual_style_preset, + review_camera): viewport_setting.VisualStyleMode = rt.Name( visual_style_preset) preview_arg = set_preview_arg( diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 731dac74e3..f0f349cd77 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -51,9 +51,9 @@ class ExtractThumbnail(publish.Extractor): nitrousGraphicMgr = rt.NitrousGraphicsManager viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() with viewport_setup( - viewport_setting, - visual_style_preset, - review_camera): + viewport_setting, + visual_style_preset, + review_camera): viewport_setting.VisualStyleMode = rt.Name( visual_style_preset) preview_arg = set_preview_arg( From 32820f4bfa7574c366caae4295828f4efdcb0370 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 12 Oct 2023 16:24:13 +0800 Subject: [PATCH 0545/1224] add viewport Tetxure support for preview --- openpype/hosts/max/api/lib.py | 8 ++++++-- openpype/hosts/max/plugins/publish/collect_review.py | 12 ++++++++++-- .../hosts/max/plugins/publish/extract_thumbnail.py | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index fe2742cdb0..26ca5ed1d8 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -578,8 +578,12 @@ def set_preview_arg(instance, filepath, preview_preset = "userdefined" else: preview_preset = preview_preset.lower() - preview_preset.option = f"vpPreset:#{visual_style_preset}" - job_args.append(preview_preset) + preview_preset_option = f"vpPreset:#{visual_style_preset}" + job_args.append(preview_preset_option) + viewport_texture = instance.data.get("vpTexture", True) + if viewport_texture: + viewport_texture_option = f"vpTexture:{viewport_texture}" + job_args.append(viewport_texture_option) job_str = " ".join(job_args) log.debug(job_str) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 1f0dca5329..8b9a777c63 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -59,6 +59,7 @@ class CollectReview(pyblish.api.InstancePlugin, instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform + instance.data["vpTexture"] = attr_values.get("vpTexture") # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -66,12 +67,19 @@ class CollectReview(pyblish.api.InstancePlugin, burnin_members = instance.data.setdefault("burninDataMembers", {}) burnin_members["focalLength"] = focal_length - self.log.debug(f"data:{data}") instance.data.update(data) + self.log.debug(f"data:{data}") @classmethod def get_attribute_defs(cls): - return [ + additional_attrs = [] + if int(get_max_version()) >= 2024: + additional_attrs.append( + BoolDef("vpTexture", + label="Viewport Texture", + default=True), + ) + return additional_attrs + [ BoolDef("dspGeometry", label="Geometry", default=True), diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index f0f349cd77..890ee24f8e 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -41,7 +41,7 @@ class ExtractThumbnail(publish.Extractor): "Writing Thumbnail to" " '%s' to '%s'" % (filename, tmp_staging)) review_camera = instance.data["review_camera"] - if get_max_version() >= 2024: + if int(get_max_version()) >= 2024: with viewport_setup_updated(review_camera): preview_arg = set_preview_arg( instance, filepath, frame, frame, fps) From 6d04bcd7acc8fb304163795f4f74b9d866add5ae Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 12 Oct 2023 16:40:31 +0800 Subject: [PATCH 0546/1224] make sure get max version is integer --- openpype/hosts/max/plugins/publish/extract_review_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index acabd74958..da3f4155c1 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -37,7 +37,7 @@ class ExtractReviewAnimation(publish.Extractor): " '%s' to '%s'" % (filename, staging_dir)) review_camera = instance.data["review_camera"] - if get_max_version() >= 2024: + if int(get_max_version()) >= 2024: with viewport_setup_updated(review_camera): preview_arg = set_preview_arg( instance, filepath, start, end, fps) From 68b281fdedad5df6339452418335e5f7f771ca07 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:10:29 +0100 Subject: [PATCH 0547/1224] Improved how models abc extractor is implemented Co-authored-by: Kayla Man --- .../plugins/publish/collect_instances.py | 5 +---- .../blender/plugins/publish/extract_abc.py | 10 +++++++++- .../plugins/publish/extract_abc_model.py | 17 ----------------- 3 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 openpype/hosts/blender/plugins/publish/extract_abc_model.py diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index b4fc167638..c95d718187 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -50,10 +50,10 @@ class CollectInstances(pyblish.api.ContextPlugin): for group in asset_groups: instance = self.create_instance(context, group) - family = instance.data["family"] members = [] if type(group) == bpy.types.Collection: members = list(group.objects) + family = instance.data["family"] if family == "animation": for obj in group.objects: if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): @@ -63,9 +63,6 @@ class CollectInstances(pyblish.api.ContextPlugin): else: members = group.children_recursive - if family == "pointcache": - instance.data["families"].append("abc.export") - members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index b113685842..a603366f30 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -12,7 +12,7 @@ class ExtractABC(publish.Extractor): label = "Extract ABC" hosts = ["blender"] - families = ["abc.export"] + families = ["pointcache"] def process(self, instance): # Define extract output file path @@ -61,3 +61,11 @@ class ExtractABC(publish.Extractor): self.log.info("Extracted instance '%s' to: %s", instance.name, representation) + +class ExtractModelABC(ExtractABC): + """Extract model as ABC.""" + + label = "Extract Model ABC" + hosts = ["blender"] + families = ["model"] + optional = True diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_model.py b/openpype/hosts/blender/plugins/publish/extract_abc_model.py deleted file mode 100644 index b31e36c681..0000000000 --- a/openpype/hosts/blender/plugins/publish/extract_abc_model.py +++ /dev/null @@ -1,17 +0,0 @@ -import pyblish.api -from openpype.pipeline import publish - - -class ExtractModelABC(publish.Extractor): - """Extract model as ABC.""" - - order = pyblish.api.ExtractorOrder - 0.1 - label = "Extract Model ABC" - hosts = ["blender"] - families = ["model"] - optional = True - - def process(self, instance): - # Add abc.export family to the instance, to allow the extraction - # as alembic of the asset. - instance.data["families"].append("abc.export") From 7cce128c2027f88cb5dc54d526ff3f944dc3a14f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:11:28 +0100 Subject: [PATCH 0548/1224] Hound fixes --- openpype/hosts/blender/plugins/publish/extract_abc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index a603366f30..7b6c4d7ae7 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -62,6 +62,7 @@ class ExtractABC(publish.Extractor): self.log.info("Extracted instance '%s' to: %s", instance.name, representation) + class ExtractModelABC(ExtractABC): """Extract model as ABC.""" From 6259687b32c9216f0f111e07b5c6228242d1d25e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:14:30 +0100 Subject: [PATCH 0549/1224] Increment workfile version when publishing pointcache --- .../hosts/blender/plugins/publish/increment_workfile_version.py | 2 +- 1 file changed, 1 insertion(+), 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 3d176f9c30..6ace14d77c 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", - "render"] + "pointcache", "render"] def process(self, context): From e7cd31f2dd5521e6bb8d30e908958f6c3706628a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 12 Oct 2023 11:22:53 +0200 Subject: [PATCH 0550/1224] 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 95fefaaa169a7404d3fae9ed5906b1227cde7c95 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:45:16 +0100 Subject: [PATCH 0551/1224] Fix wrong hierarchy when loading --- .../hosts/blender/plugins/load/load_abc.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 9b3d940536..a7077f98f2 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -60,16 +60,30 @@ class CacheModelLoader(plugin.AssetLoader): imported = lib.get_selection() + empties = [obj for obj in imported if obj.type == 'EMPTY'] + + container = None + + for empty in empties: + if not empty.parent: + container = empty + break + + assert container, "No asset group found" + # Children must be linked before parents, # otherwise the hierarchy will break objects = [] + nodes = list(container.children) - for obj in imported: + for obj in nodes: obj.parent = asset_group - for obj in imported: + bpy.data.objects.remove(container) + + for obj in nodes: objects.append(obj) - imported.extend(list(obj.children)) + nodes.extend(list(obj.children)) objects.reverse() From 38427b5eecb38b6ffdc2494826907fe542c025b6 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 12 Oct 2023 11:07:09 +0100 Subject: [PATCH 0552/1224] 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 7589de5aa14cb595f7646f68e7f9d8eaf373a0b5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 11:33:18 +0100 Subject: [PATCH 0553/1224] Improved loop to get all loaded objects --- openpype/hosts/blender/plugins/load/load_abc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index a7077f98f2..91d7356a2c 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -83,7 +83,7 @@ class CacheModelLoader(plugin.AssetLoader): for obj in nodes: objects.append(obj) - nodes.extend(list(obj.children)) + objects.extend(list(obj.children_recursive)) objects.reverse() From 251740891980ad37a7d3d326bbb833b36ced2f24 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 11:40:21 +0100 Subject: [PATCH 0554/1224] Fixed handling of missing container in the abc file being loaded --- .../hosts/blender/plugins/load/load_abc.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 91d7356a2c..531a820436 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -69,23 +69,26 @@ class CacheModelLoader(plugin.AssetLoader): container = empty break - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break objects = [] - nodes = list(container.children) + if container: + # Children must be linked before parents, + # otherwise the hierarchy will break + nodes = list(container.children) - for obj in nodes: - obj.parent = asset_group + for obj in nodes: + obj.parent = asset_group - bpy.data.objects.remove(container) + bpy.data.objects.remove(container) - for obj in nodes: - objects.append(obj) - objects.extend(list(obj.children_recursive)) + for obj in nodes: + objects.append(obj) + objects.extend(list(obj.children_recursive)) - objects.reverse() + objects.reverse() + else: + for obj in imported: + obj.parent = asset_group + objects = imported for obj in objects: # Unlink the object from all collections From 5b3c6b8cfde60a9d053b093e1401b089aa6db8e0 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 12 Oct 2023 13:16:14 +0100 Subject: [PATCH 0555/1224] 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 0556/1224] 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 0557/1224] [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 0558/1224] 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 0559/1224] 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 0560/1224] 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 0561/1224] 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 0562/1224] 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 848953f026493244a0de98bbf6df6f6d0f421e73 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 12 Oct 2023 18:01:12 +0300 Subject: [PATCH 0563/1224] add sections --- server_addon/houdini/server/settings/publish.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 5a1ee1fa07..ab1b71c6bb 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -25,7 +25,8 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): ValidateContainers: BasicValidateModel = Field( default_factory=BasicValidateModel, - title="Validate Latest Containers.") + title="Validate Latest Containers.", + section="Validators") ValidateMeshIsStatic: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Mesh is Static.") From 05dc8f557da1edddf3d53991bb0d4f766a6dc9bd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 12 Oct 2023 18:02:13 +0300 Subject: [PATCH 0564/1224] Align Openpype with Ayon --- .../defaults/project_settings/houdini.json | 160 +++++++++++------- .../schemas/schema_houdini_create.json | 92 ++++++---- .../schemas/schema_houdini_publish.json | 56 +++--- 3 files changed, 187 insertions(+), 121 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 4f57ee52c6..f28beac65d 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -24,6 +24,12 @@ }, "shelves": [], "create": { + "CreateAlembicCamera": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, "CreateArnoldAss": { "enabled": true, "default_variants": [ @@ -31,6 +37,66 @@ ], "ext": ".ass" }, + "CreateArnoldRop": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateCompositeSequence": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateHDA": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateKarmaROP": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateMantraROP": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreatePointCache": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateBGEO": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateRedshiftProxy": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateRedshiftROP": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateReview": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, "CreateStaticMesh": { "enabled": true, "default_variants": [ @@ -44,31 +110,13 @@ "UCX" ] }, - "CreateAlembicCamera": { + "CreateUSD": { "enabled": true, "default_variants": [ "Main" ] }, - "CreateCompositeSequence": { - "enabled": true, - "default_variants": [ - "Main" - ] - }, - "CreatePointCache": { - "enabled": true, - "default_variants": [ - "Main" - ] - }, - "CreateRedshiftROP": { - "enabled": true, - "default_variants": [ - "Main" - ] - }, - "CreateRemotePublish": { + "CreateUSDRender": { "enabled": true, "default_variants": [ "Main" @@ -80,32 +128,39 @@ "Main" ] }, - "CreateUSD": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "CreateUSDModel": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "USDCreateShadingWorkspace": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "CreateUSDRender": { - "enabled": false, + "CreateVrayROP": { + "enabled": true, "default_variants": [ "Main" ] } }, "publish": { + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateMeshIsStatic": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateReviewColorspace": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateSubsetName": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateUnrealStaticMeshName": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateWorkfilePaths": { "enabled": true, "optional": true, @@ -117,31 +172,6 @@ "$HIP", "$JOB" ] - }, - "ValidateReviewColorspace": { - "enabled": true, - "optional": true, - "active": true - }, - "ValidateContainers": { - "enabled": true, - "optional": true, - "active": true - }, - "ValidateSubsetName": { - "enabled": true, - "optional": true, - "active": true - }, - "ValidateMeshIsStatic": { - "enabled": true, - "optional": true, - "active": true - }, - "ValidateUnrealStaticMeshName": { - "enabled": false, - "optional": true, - "active": true } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index cd8c260124..f37738c4ec 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -4,6 +4,16 @@ "key": "create", "label": "Creator plugins", "children": [ + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + } + ] + }, { "type": "dict", "collapsible": true, @@ -39,6 +49,52 @@ ] }, + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateArnoldRop", + "label": "Create Arnold ROP" + }, + { + "key": "CreateCompositeSequence", + "label": "Create Composite (Image Sequence)" + }, + { + "key": "CreateHDA", + "label": "Create Houdini Digital Asset" + }, + { + "key": "CreateKarmaROP", + "label": "Create Karma ROP" + }, + { + "key": "CreateMantraROP", + "label": "Create Mantra ROP" + }, + { + "key": "CreatePointCache", + "label": "Create PointCache (Abc)" + }, + { + "key": "CreateBGEO", + "label": "Create PointCache (Bgeo)" + }, + { + "key": "CreateRedshiftProxy", + "label": "Create Redshift Proxy" + }, + { + "key": "CreateRedshiftROP", + "label": "Create Redshift ROP" + }, + { + "key": "CreateReview", + "label": "Create Review" + } + ] + }, { "type": "dict", "collapsible": true, @@ -75,44 +131,20 @@ "name": "template_create_plugin", "template_data": [ { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" + "key": "CreateUSD", + "label": "Create USD (experimental)" }, { - "key": "CreateCompositeSequence", - "label": "Create Composite (Image Sequence)" - }, - { - "key": "CreatePointCache", - "label": "Create Point Cache" - }, - { - "key": "CreateRedshiftROP", - "label": "Create Redshift ROP" - }, - { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" + "key": "CreateUSDRender", + "label": "Create USD render (experimental)" }, { "key": "CreateVDBCache", "label": "Create VDB Cache" }, { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" + "key": "CreateVrayROP", + "label": "Create VRay ROP" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index d5f70b0312..e202e7b615 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -4,6 +4,36 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "label", + "label": "Validators" + }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "Validate Containers" + }, + { + "key": "ValidateMeshIsStatic", + "label": "Validate Mesh is Static" + }, + { + "key": "ValidateReviewColorspace", + "label": "Validate Review Colorspace" + }, + { + "key": "ValidateSubsetName", + "label": "Validate Subset Name" + }, + { + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name" + } + ] + }, { "type": "dict", "collapsible": true, @@ -35,32 +65,6 @@ "object_type": "text" } ] - }, - { - "type": "schema_template", - "name": "template_publish_plugin", - "template_data": [ - { - "key": "ValidateReviewColorspace", - "label": "Validate Review Colorspace" - }, - { - "key": "ValidateContainers", - "label": "ValidateContainers" - }, - { - "key": "ValidateSubsetName", - "label": "Validate Subset Name" - }, - { - "key": "ValidateMeshIsStatic", - "label": "Validate Mesh is Static" - }, - { - "key": "ValidateUnrealStaticMeshName", - "label": "Validate Unreal Static Mesh Name" - } - ] } ] } From 0953dc65cc915bd78cd1b37ba4305ce4fc705aa8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 12 Oct 2023 17:16:35 +0200 Subject: [PATCH 0565/1224] 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 0566/1224] 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 0567/1224] 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 21de693c17adaeebdf4ac30cd198347474a7efa2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 17:07:47 +0100 Subject: [PATCH 0568/1224] Implemented inventory plugin to update actors in current level --- openpype/hosts/unreal/api/pipeline.py | 4 ++ .../unreal/plugins/inventory/update_actors.py | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 openpype/hosts/unreal/plugins/inventory/update_actors.py diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 2893550325..60b4886e4f 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -13,8 +13,10 @@ from openpype.client import get_asset_by_name, get_assets from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, + register_inventory_action_path, deregister_loader_plugin_path, deregister_creator_plugin_path, + deregister_inventory_action_path, AYON_CONTAINER_ID, legacy_io, ) @@ -127,6 +129,7 @@ def install(): pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) + register_inventory_action_path(str(INVENTORY_PATH)) _register_callbacks() _register_events() @@ -136,6 +139,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) deregister_creator_plugin_path(str(CREATE_PATH)) + deregister_inventory_action_path(str(INVENTORY_PATH)) def _register_callbacks(): diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py new file mode 100644 index 0000000000..672bb2b32e --- /dev/null +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -0,0 +1,60 @@ +import unreal + +from openpype.hosts.unreal.api.pipeline import ( + ls, + replace_static_mesh_actors, + replace_skeletal_mesh_actors, + delete_previous_asset_if_unused, +) +from openpype.pipeline import InventoryAction + + +class UpdateActors(InventoryAction): + """Update Actors in level to this version. + """ + + label = "Update Actors in level to this version" + icon = "arrow-up" + + def process(self, containers): + allowed_families = ["model", "rig"] + + # Get all the containers in the Unreal Project + all_containers = ls() + + for container in containers: + container_dir = container.get("namespace") + if container.get("family") not in allowed_families: + unreal.log_warning( + f"Container {container_dir} is not supported.") + continue + + # Get all containers with same asset_name but different objectName. + # These are the containers that need to be updated in the level. + sa_containers = [ + i + for i in all_containers + if ( + i.get("asset_name") == container.get("asset_name") and + i.get("objectName") != container.get("objectName") + ) + ] + + asset_content = unreal.EditorAssetLibrary.list_assets( + container_dir, recursive=True, include_folder=False + ) + + # Update all actors in level + for sa_cont in sa_containers: + sa_dir = sa_cont.get("namespace") + old_content = unreal.EditorAssetLibrary.list_assets( + sa_dir, recursive=True, include_folder=False + ) + + if container.get("family") == "rig": + replace_skeletal_mesh_actors(old_content, asset_content) + replace_static_mesh_actors(old_content, asset_content) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(sa_cont, old_content) From a6aada9c13cf97c70b4538fdb3c09afb33d960f9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 17:22:52 +0100 Subject: [PATCH 0569/1224] Fixed issue with the updater for GeometryCaches --- openpype/hosts/unreal/api/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 60b4886e4f..760e052a3e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -722,8 +722,8 @@ def replace_skeletal_mesh_actors(old_assets, new_assets): def replace_geometry_cache_actors(old_assets, new_assets): geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets( - unreal.SkeletalMeshComponent, - unreal.SkeletalMesh, + unreal.GeometryCacheComponent, + unreal.GeometryCache, old_assets, new_assets ) From 5fa8a6c4eae338064d8c028fb4b7342ec9868fc2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 17:23:14 +0100 Subject: [PATCH 0570/1224] Added support for GeometryCaches to the new inventory plugin --- openpype/hosts/unreal/plugins/inventory/update_actors.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py index 672bb2b32e..37777114e2 100644 --- a/openpype/hosts/unreal/plugins/inventory/update_actors.py +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -4,6 +4,7 @@ from openpype.hosts.unreal.api.pipeline import ( ls, replace_static_mesh_actors, replace_skeletal_mesh_actors, + replace_geometry_cache_actors, delete_previous_asset_if_unused, ) from openpype.pipeline import InventoryAction @@ -53,7 +54,13 @@ class UpdateActors(InventoryAction): if container.get("family") == "rig": replace_skeletal_mesh_actors(old_content, asset_content) - replace_static_mesh_actors(old_content, asset_content) + replace_static_mesh_actors(old_content, asset_content) + elif container.get("family") == "model": + if container.get("loader") == "PointCacheAlembicLoader": + replace_geometry_cache_actors( + old_content, asset_content) + else: + replace_static_mesh_actors(old_content, asset_content) unreal.EditorLevelLibrary.save_current_level() From 1a492757fa454fbf12aace9dcede1fc16d3ccf0a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 17:25:57 +0100 Subject: [PATCH 0571/1224] Updating assets no longer updates actors in current level --- .../unreal/plugins/load/load_geometrycache_abc.py | 10 ---------- .../unreal/plugins/load/load_skeletalmesh_abc.py | 11 ----------- .../unreal/plugins/load/load_skeletalmesh_fbx.py | 11 ----------- .../hosts/unreal/plugins/load/load_staticmesh_abc.py | 10 ---------- .../hosts/unreal/plugins/load/load_staticmesh_fbx.py | 10 ---------- 5 files changed, 52 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index 8ac2656bd7..3e128623a6 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -207,16 +207,6 @@ class PointCacheAlembicLoader(plugin.Loader): for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) - old_assets = unreal.EditorAssetLibrary.list_assets( - container["namespace"], recursive=True, include_folder=False - ) - - replace_geometry_cache_actors(old_assets, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(container, old_assets) - def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 2e6557bd2d..b7c09fc02b 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -182,17 +182,6 @@ class SkeletalMeshAlembicLoader(plugin.Loader): for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) - old_assets = unreal.EditorAssetLibrary.list_assets( - container["namespace"], recursive=True, include_folder=False - ) - - replace_static_mesh_actors(old_assets, asset_content) - replace_skeletal_mesh_actors(old_assets, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(container, old_assets) - def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 3c84f36399..112eba51ff 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -184,17 +184,6 @@ class SkeletalMeshFBXLoader(plugin.Loader): for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) - old_assets = unreal.EditorAssetLibrary.list_assets( - container["namespace"], recursive=True, include_folder=False - ) - - replace_static_mesh_actors(old_assets, asset_content) - replace_skeletal_mesh_actors(old_assets, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(container, old_assets) - def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index cc7aed7b93..af098b3ec9 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -182,16 +182,6 @@ class StaticMeshAlembicLoader(plugin.Loader): for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) - old_assets = unreal.EditorAssetLibrary.list_assets( - container["namespace"], recursive=True, include_folder=False - ) - - replace_static_mesh_actors(old_assets, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(container, old_assets) - def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index 0aac69b57b..4b20bb485c 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -171,16 +171,6 @@ class StaticMeshFBXLoader(plugin.Loader): for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) - old_assets = unreal.EditorAssetLibrary.list_assets( - container["namespace"], recursive=True, include_folder=False - ) - - replace_static_mesh_actors(old_assets, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(container, old_assets) - def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) From 27319255417e8865a54f1cfd68ac61e577c35340 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 17:27:23 +0100 Subject: [PATCH 0572/1224] Hound fixes --- openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py | 2 -- openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py | 3 --- openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py | 3 --- openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py | 2 -- openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py | 2 -- 5 files changed, 12 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index 3e128623a6..e64a5654a1 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -10,8 +10,6 @@ from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( create_container, imprint, - replace_geometry_cache_actors, - delete_previous_asset_if_unused, ) import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index b7c09fc02b..03695bb93b 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -10,9 +10,6 @@ from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( create_container, imprint, - replace_static_mesh_actors, - replace_skeletal_mesh_actors, - delete_previous_asset_if_unused, ) import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 112eba51ff..7640ecfa9e 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -10,9 +10,6 @@ from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( create_container, imprint, - replace_static_mesh_actors, - replace_skeletal_mesh_actors, - delete_previous_asset_if_unused, ) import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index af098b3ec9..a3ea2a2231 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -10,8 +10,6 @@ from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( create_container, imprint, - replace_static_mesh_actors, - delete_previous_asset_if_unused, ) import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index 4b20bb485c..44d7ca631e 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -10,8 +10,6 @@ from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( create_container, imprint, - replace_static_mesh_actors, - delete_previous_asset_if_unused, ) import unreal # noqa From 873c263587703db10872bbb16536413628cbfdc2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 12 Oct 2023 21:15:23 +0300 Subject: [PATCH 0573/1224] add a TODO in shelves manager --- server_addon/houdini/server/settings/shelves.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 2319357f59..c8bda515f9 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -9,6 +9,7 @@ from ayon_server.settings import ( class ShelfToolsModel(BaseSettingsModel): name: str = Field(title="Name") help: str = Field(title="Help text") + # TODO: The following settings are not compatible with OP script: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, title="Script Path " From dcfad64320085041cf6b91577b3de605acde1f02 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 16:32:34 +0800 Subject: [PATCH 0574/1224] add families with frame range back to the frame range validator --- openpype/hosts/max/plugins/publish/validate_frame_range.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 21e847405e..43692d0401 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -27,7 +27,9 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, label = "Validate Frame Range" order = ValidateContentsOrder - families = ["maxrender"] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] 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 0575/1224] 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 0576/1224] 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 0577/1224] 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 0578/1224] 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 0579/1224] 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 6f8cc1c982f2378fd83430ab4fa640b8dcaffa09 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 13 Oct 2023 12:28:44 +0200 Subject: [PATCH 0580/1224] fixing settings for `ValidateScriptAttributes` --- openpype/settings/defaults/project_settings/nuke.json | 2 +- .../projects_schema/schemas/schema_nuke_publish.json | 4 ++-- server_addon/nuke/server/settings/publish_plugins.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index ad9f46c8ab..262381e15a 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -374,7 +374,7 @@ "optional": true, "active": true }, - "ValidateScript": { + "ValidateScriptAttributes": { "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..8877494053 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 @@ -113,8 +113,8 @@ "label": "Validate Gizmo (Group)" }, { - "key": "ValidateScript", - "label": "Validate script settings" + "key": "ValidateScriptAttributes", + "label": "Validate workfile attributes" } ] }, diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 2d3a282c46..25a70b47ba 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -263,8 +263,8 @@ class PublishPuginsModel(BaseSettingsModel): title="Validate Backdrop", default_factory=OptionalPluginModel ) - ValidateScript: OptionalPluginModel = Field( - title="Validate Script", + ValidateScriptAttributes: OptionalPluginModel = Field( + title="Validate workfile attributes", default_factory=OptionalPluginModel ) ExtractThumbnail: ExtractThumbnailModel = Field( @@ -345,7 +345,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "optional": True, "active": True }, - "ValidateScript": { + "ValidateScriptAttributes": { "enabled": True, "optional": True, "active": True 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 0581/1224] 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 0582/1224] 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 0583/1224] 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 0584/1224] 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 0585/1224] :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 0586/1224] :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 0587/1224] :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 1b79767e7bbd76f93ca8ba8bf0f2ef434239509c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:15:14 +0800 Subject: [PATCH 0588/1224] add families into frame range collector and improve the validation report in frame range validator --- .../plugins/publish/collect_frame_range.py | 23 +++++++++ .../max/plugins/publish/collect_review.py | 2 - .../max/plugins/publish/extract_camera_abc.py | 4 +- .../max/plugins/publish/extract_pointcache.py | 4 +- .../max/plugins/publish/extract_pointcloud.py | 4 +- .../plugins/publish/extract_redshift_proxy.py | 4 +- .../publish/validate_animation_timeline.py | 48 ------------------- .../plugins/publish/validate_frame_range.py | 45 +++++++++++------ 8 files changed, 62 insertions(+), 72 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/collect_frame_range.py delete mode 100644 openpype/hosts/max/plugins/publish/validate_animation_timeline.py diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py new file mode 100644 index 0000000000..197ecff0b1 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Collect instance members.""" +import pyblish.api +from pymxs import runtime as rt + + +class CollectFrameRange(pyblish.api.InstancePlugin): + """Collect Set Members.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect Frame Range" + hosts = ['max'] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review"] + + def process(self, instance): + if instance.data["family"] == "maxrender": + instance.data["frameStart"] = int(rt.rendStart) + instance.data["frameEnd"] = int(rt.rendEnd) + else: + instance.data["frameStart"] = int(rt.animationRange.start) + instance.data["frameEnd"] = int(rt.animationRange.end) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 8e27a857d7..cc4caae497 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,8 +29,6 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, - "frameStart": instance.context.data["frameStart"], - "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index b1918c53e0..ea33bc67ed 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.info("Extracting Camera ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index c3de623bc0..a5480ff0dc 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor): families = ["pointcache"] def process(self, instance): - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.debug("Extracting pointcache ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 583bbb6dbd..de90229c59 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -39,8 +39,8 @@ class ExtractPointCloud(publish.Extractor): def process(self, instance): self.settings = self.get_setting(instance) - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index f67ed30c6b..4f64e88584 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py deleted file mode 100644 index 2a9483c763..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py +++ /dev/null @@ -1,48 +0,0 @@ -import pyblish.api - -from pymxs import runtime as rt -from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError -) -from openpype.hosts.max.api.lib import get_frame_range, set_timeline - - -class ValidateAnimationTimeline(pyblish.api.InstancePlugin): - """ - Validates Animation Timeline for Preview Animation in Max - """ - - label = "Animation Timeline for Review" - order = ValidateContentsOrder - families = ["review"] - hosts = ["max"] - actions = [RepairAction] - - def process(self, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - if rt.animationRange.start != frame_start_handle or ( - rt.animationRange.end != frame_end_handle - ): - raise PublishValidationError("Incorrect animation timeline " - "set for preview animation.. " - "\nYou can use repair action to " - "the correct animation timeline") - - @classmethod - def repair(cls, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 43692d0401..a50a3910c7 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -9,6 +9,7 @@ from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) +from openpype.hosts.max.api.lib import get_frame_range, set_timeline class ValidateFrameRange(pyblish.api.InstancePlugin, @@ -29,7 +30,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, order = ValidateContentsOrder families = ["camera", "maxrender", "pointcache", "pointcloud", - "review", "redshiftproxy"] + "review"] hosts = ["max"] optional = True actions = [RepairAction] @@ -38,29 +39,45 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, if not self.is_active(instance.data): self.log.info("Skipping validation...") return - context = instance.context - frame_start = int(context.data.get("frameStart")) - frame_end = int(context.data.get("frameEnd")) - - inst_frame_start = int(instance.data.get("frameStart")) - inst_frame_end = int(instance.data.get("frameEnd")) + frame_range = get_frame_range() + inst_frame_start = instance.data.get("frameStart") + inst_frame_end = instance.data.get("frameEnd") + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) errors = [] - if frame_start != inst_frame_start: + if frame_start_handle != inst_frame_start: errors.append( f"Start frame ({inst_frame_start}) on instance does not match " # noqa - f"with the start frame ({frame_start}) set on the asset data. ") # noqa - if frame_end != inst_frame_end: + f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa + if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_start}) from the asset data. ") + f"with the end frame ({frame_end_handle}) from the asset data. ") if errors: errors.append("You can use repair action to fix it.") - raise PublishValidationError("\n".join(errors)) + report = "Frame range settings are incorrect.\n\n" + for error in errors: + report += "- {}\n\n".format(error) + raise PublishValidationError(report, title="Frame Range incorrect") @classmethod def repair(cls, instance): - rt.rendStart = instance.context.data.get("frameStart") - rt.rendEnd = instance.context.data.get("frameEnd") + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + if instance.data["family"] == "maxrender": + rt.rendStart = frame_start_handle + rt.rendEnd = frame_end_handle + else: + set_timeline(frame_start_handle, frame_end_handle) From f45c603da29da954a44aade1e23d5ce304ccc8f1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:16:21 +0800 Subject: [PATCH 0589/1224] add redshift proxy family --- openpype/hosts/max/plugins/publish/collect_frame_range.py | 2 +- openpype/hosts/max/plugins/publish/validate_frame_range.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 197ecff0b1..6e5f928a8e 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -12,7 +12,7 @@ class CollectFrameRange(pyblish.api.InstancePlugin): hosts = ['max'] families = ["camera", "maxrender", "pointcache", "pointcloud", - "review"] + "review", "redshiftproxy"] def process(self, instance): if instance.data["family"] == "maxrender": diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index a50a3910c7..cf4d02c830 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -30,7 +30,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, order = ValidateContentsOrder families = ["camera", "maxrender", "pointcache", "pointcloud", - "review"] + "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] From f9dcd4bce67dd35c111f184a07cf489b07ed3537 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:18:22 +0800 Subject: [PATCH 0590/1224] hound --- openpype/hosts/max/plugins/publish/validate_frame_range.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index cf4d02c830..b1e8aafbb7 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -58,7 +58,8 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_end_handle}) from the asset data. ") + f"with the end frame ({frame_end_handle}) " + "from the asset data. ") if errors: errors.append("You can use repair action to fix it.") From 4ba25d35d3b8c8848bced077b9cc0f20b084bb32 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:20:33 +0800 Subject: [PATCH 0591/1224] 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 59b7c61b3da7cb95b6b62c731ea0496dc83bac8a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:28:45 +0800 Subject: [PATCH 0592/1224] docstring for collect frane rabge --- openpype/hosts/max/plugins/publish/collect_frame_range.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 6e5f928a8e..2dd39b5b50 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -5,7 +5,7 @@ from pymxs import runtime as rt class CollectFrameRange(pyblish.api.InstancePlugin): - """Collect Set Members.""" + """Collect Frame Range.""" order = pyblish.api.CollectorOrder + 0.01 label = "Collect Frame Range" 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 0593/1224] 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 0594/1224] 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 0595/1224] 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 0596/1224] :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 0597/1224] :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 0598/1224] [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 0599/1224] 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 0600/1224] :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 0601/1224] :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 7431f6e9ef68b95ad0dc6c7ac0e9b1c1656672ce Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 16 Oct 2023 16:06:58 +0800 Subject: [PATCH 0602/1224] make sure original basename is used during publishing as the tyc export needs very strict naming --- openpype/hosts/max/plugins/load/load_tycache.py | 2 +- .../plugins/publish/collect_tycache_attributes.py | 4 +++- .../hosts/max/plugins/publish/extract_tycache.py | 15 ++++++++------- .../defaults/project_anatomy/templates.json | 6 ++++++ .../defaults/project_settings/global.json | 11 +++++++++++ server_addon/core/server/settings/tools.py | 11 +++++++++++ server_addon/core/server/version.py | 2 +- 7 files changed, 41 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index 7eac0de3e5..ff3a26fbd6 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -13,7 +13,7 @@ from openpype.hosts.max.api.pipeline import ( from openpype.pipeline import get_representation_path, load -class PointCloudLoader(load.LoaderPlugin): +class TyCacheLoader(load.LoaderPlugin): """Point Cloud Loader.""" families = ["tycache"] diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index d735b2f2c0..56cf6614e2 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -42,6 +42,7 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, "tycacheChanMaterials", "tycacheChanCustomFloat" "tycacheChanCustomVector", "tycacheChanCustomTM", "tycacheChanPhysX", "tycacheMeshBackup", + "tycacheCreateObject", "tycacheCreateObjectIfNotCreated", "tycacheAdditionalCloth", "tycacheAdditionalSkin", @@ -59,7 +60,8 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", "tycacheChanShape", "tycacheChanMatID", "tycacheChanMapping", - "tycacheChanMaterials"] + "tycacheChanMaterials", "tycacheCreateObject", + "tycacheCreateObjectIfNotCreated"] return [ EnumDef("all_tyc_attrs", tyc_attr_enum, diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 0327564b3a..a787080776 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -37,7 +37,7 @@ class ExtractTyCache(publish.Extractor): stagingdir = self.staging_dir(instance) filename = "{name}.tyc".format(**instance.data) path = os.path.join(stagingdir, filename) - filenames = self.get_file(instance, start, end) + filenames = self.get_files(instance, start, end) additional_attributes = instance.data.get("tyc_attrs", {}) with maintained_selection(): @@ -55,14 +55,14 @@ class ExtractTyCache(publish.Extractor): tycache_spline_enabled=has_tyc_spline) for job in job_args: rt.Execute(job) - + representations = instance.data.setdefault("representations", []) representation = { 'name': 'tyc', 'ext': 'tyc', 'files': filenames if len(filenames) > 1 else filenames[0], - "stagingDir": stagingdir + "stagingDir": stagingdir, } - instance.data["representations"].append(representation) + representations.append(representation) self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") # Get the tyMesh filename for extraction @@ -71,13 +71,14 @@ class ExtractTyCache(publish.Extractor): 'name': 'tyMesh', 'ext': 'tyc', 'files': mesh_filename, - "stagingDir": stagingdir + "stagingDir": stagingdir, + "outputName": '__tyMesh' } - instance.data["representations"].append(mesh_repres) + representations.append(mesh_repres) self.log.info( f"Extracted instance '{instance.name}' to: {mesh_filename}") - def get_file(self, instance, start_frame, end_frame): + def get_files(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. Set the filenames accordingly to the tyCache file diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index e5e535bf19..aa3f8d4843 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -53,6 +53,11 @@ "file": "{originalBasename}<.{@frame}><_{udim}>.{ext}", "path": "{@folder}/{@file}" }, + "tycache": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "file": "{originalBasename}<_{@version}><_{@frame}>.{ext}", + "path": "{@folder}/{@file}" + }, "source": { "folder": "{root[work]}/{originalDirname}", "file": "{originalBasename}.{ext}", @@ -66,6 +71,7 @@ "simpleUnrealTextureHero": "Simple Unreal Texture - Hero", "simpleUnrealTexture": "Simple Unreal Texture", "online": "online", + "tycache": "tycache", "source": "source", "transient": "transient" } diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 06a595d1c5..9ccf5cae05 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -546,6 +546,17 @@ "task_types": [], "task_names": [], "template_name": "online" + }, + { + "families": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "tycache" } ], "hero_template_name_profiles": [ diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 7befc795e4..d7c7b367b7 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -487,6 +487,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "publish_online" + }, + { + "families": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "publish_tycache" } ], "hero_template_name_profiles": [ diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 4a0f87c5179959e6375ace26c0307ff7c29c0e9e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 16 Oct 2023 16:09:08 +0800 Subject: [PATCH 0603/1224] updated the file naming convention --- openpype/settings/defaults/project_anatomy/templates.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index aa3f8d4843..5694693c97 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -55,7 +55,7 @@ }, "tycache": { "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", - "file": "{originalBasename}<_{@version}><_{@frame}>.{ext}", + "file": "{originalBasename}<_{@frame}>.{ext}", "path": "{@folder}/{@file}" }, "source": { From 4bb51b91de8a903cc3540f3582cc22440fda60a0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Oct 2023 10:09:39 +0200 Subject: [PATCH 0604/1224] :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 84e77a970719618710e68bd17a9a69bdf5311442 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 16 Oct 2023 12:30:49 +0200 Subject: [PATCH 0605/1224] fixing unc paths on windows with backward slashes --- openpype/hosts/nuke/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 62f3a3c3ff..b061271f5a 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2222,7 +2222,6 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. """ # replace path with env var if possible ocio_path = self._replace_ocio_path_with_env_var(config_data) - ocio_path = ocio_path.replace("\\", "/") log.info("Setting OCIO config path to: `{}`".format( ocio_path)) @@ -2303,7 +2302,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. if env_path in path: # with regsub we make sure path format of slashes is correct resub_expr = ( - "[regsub -all {{\\\\}} [getenv {}] \"/\"]").format(env_var) + "[regsub -all {{\\}} [getenv {}] \"/\"]").format(env_var) new_path = path.replace( env_path, resub_expr From 9cd8c864eb10a0d646259fc18e96d9f21315ff5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 17:37:50 +0200 Subject: [PATCH 0606/1224] 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 0607/1224] 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 0608/1224] 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 0609/1224] 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 f12a2fb504d6ef449897e5f338f37d67b9b86215 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 11:29:52 +0200 Subject: [PATCH 0610/1224] Nuke: gizmo loading representations fixed --- openpype/hosts/nuke/plugins/load/load_gizmo.py | 2 +- openpype/hosts/nuke/plugins/load/load_gizmo_ip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index ede05c422b..5d028fc2db 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 = ["*"] - extensions = {"gizmo"} + extensions = {"nk"} 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 d567aaf7b0..ba2de3d05d 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 = ["*"] - extensions = {"gizmo"} + extensions = {"nk"} label = "Load Gizmo - Input Process" order = 0 From 9ce0ef1d9c2942bbaaf3920ce53ac217d3674bee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 11:31:40 +0200 Subject: [PATCH 0611/1224] 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 c8248dfc9ccc780c66c77bf8aa5d8ea23c257e33 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 11:34:30 +0200 Subject: [PATCH 0612/1224] updating gizmo with maintained dependencies closes https://github.com/ynput/OpenPype/issues/5501 --- openpype/hosts/nuke/api/lib.py | 67 ++++++++++++++++--- .../hosts/nuke/plugins/load/load_gizmo.py | 42 ++++++------ .../hosts/nuke/plugins/load/load_gizmo_ip.py | 45 +++++++------ 3 files changed, 103 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 62f3a3c3ff..0a5f772346 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -48,20 +48,15 @@ from openpype.pipeline import ( get_current_asset_name, ) from openpype.pipeline.context_tools import ( - get_current_project_asset, get_custom_workfile_template_from_session ) -from openpype.pipeline.colorspace import ( - get_imageio_config -) +from openpype.pipeline.colorspace import get_imageio_config from openpype.pipeline.workfile import BuildWorkfile from . import gizmo_menu from .constants import ASSIST -from .workio import ( - save_file, - open_file -) +from .workio import save_file +from .utils import get_node_outputs log = Logger.get_logger(__name__) @@ -2802,7 +2797,7 @@ def find_free_space_to_paste_nodes( @contextlib.contextmanager -def maintained_selection(): +def maintained_selection(exclude_nodes=None): """Maintain selection during context Example: @@ -2811,7 +2806,12 @@ def maintained_selection(): >>> print(node["selected"].value()) False """ + if exclude_nodes: + for node in exclude_nodes: + node["selected"].setValue(False) + previous_selection = nuke.selectedNodes() + try: yield finally: @@ -2823,6 +2823,51 @@ def maintained_selection(): select_nodes(previous_selection) +@contextlib.contextmanager +def swap_node_with_dependency(old_node, new_node): + """ Swap node with dependency + + Swap node with dependency and reconnect all inputs and outputs. + It removes old node. + + Arguments: + old_node (nuke.Node): node to be replaced + new_node (nuke.Node): node to replace with + + Example: + >>> old_node_name = old_node["name"].value() + >>> print(old_node_name) + old_node_name_01 + >>> with swap_node_with_dependency(old_node, new_node) as node_name: + ... new_node["name"].setValue(node_name) + >>> print(new_node["name"].value()) + old_node_name_01 + """ + # preserve position + xpos, ypos = old_node.xpos(), old_node.ypos() + # preserve selection after all is done + outputs = get_node_outputs(old_node) + inputs = old_node.dependencies() + node_name = old_node["name"].value() + + try: + nuke.delete(old_node) + + yield node_name + finally: + + # Reconnect inputs + for i, node in enumerate(inputs): + new_node.setInput(i, node) + # Reconnect outputs + if outputs: + for n, pipes in outputs.items(): + for i in pipes: + n.setInput(i, new_node) + # return to original position + new_node.setXYpos(xpos, ypos) + + def reset_selection(): """Deselect all selected nodes""" for node in nuke.selectedNodes(): @@ -2920,13 +2965,13 @@ def process_workfile_builder(): "workfile_builder", {}) # get settings - createfv_on = workfile_builder.get("create_first_version") or None + create_fv_on = workfile_builder.get("create_first_version") or None builder_on = workfile_builder.get("builder_on_start") or None last_workfile_path = os.environ.get("AVALON_LAST_WORKFILE") # generate first version in file not existing and feature is enabled - if createfv_on and not os.path.exists(last_workfile_path): + if create_fv_on and not os.path.exists(last_workfile_path): # get custom template path if any custom_template_path = get_custom_workfile_template_from_session( project_settings=project_settings diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 5d028fc2db..23cf4d7741 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -12,7 +12,8 @@ from openpype.pipeline import ( from openpype.hosts.nuke.api.lib import ( maintained_selection, get_avalon_knob_data, - set_avalon_knob_data + set_avalon_knob_data, + swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, @@ -45,7 +46,7 @@ class LoadGizmo(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerised nuke node object + nuke node: containerized nuke node object """ # get main variables @@ -83,12 +84,12 @@ class LoadGizmo(load.LoaderPlugin): # add group from nk nuke.nodePaste(file) - GN = nuke.selectedNode() + group_node = nuke.selectedNode() - GN["name"].setValue(object_name) + group_node["name"].setValue(object_name) return containerise( - node=GN, + node=group_node, name=name, namespace=namespace, context=context, @@ -110,7 +111,7 @@ class LoadGizmo(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + group_node = nuke.toNode(container['objectName']) file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -135,22 +136,24 @@ class LoadGizmo(load.LoaderPlugin): for k in add_keys: data_imprint.update({k: version_data[k]}) + # capture pipeline metadata + avalon_data = get_avalon_knob_data(group_node) + # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() - with maintained_selection(): - xpos = GN.xpos() - ypos = GN.ypos() - avalon_data = get_avalon_knob_data(GN) - nuke.delete(GN) - # add group from nk + with maintained_selection([group_node]): + # insert nuke script to the script nuke.nodePaste(file) - - GN = nuke.selectedNode() - set_avalon_knob_data(GN, avalon_data) - GN.setXYpos(xpos, ypos) - GN["name"].setValue(object_name) + # convert imported to selected node + new_group_node = nuke.selectedNode() + # swap nodes with maintained connections + with swap_node_with_dependency( + group_node, new_group_node) as node_name: + new_group_node["name"].setValue(node_name) + # set updated pipeline metadata + set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -161,11 +164,12 @@ class LoadGizmo(load.LoaderPlugin): color_value = self.node_color else: color_value = "0xd88467ff" - GN["tile_color"].setValue(int(color_value, 16)) + + new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) - return update_container(GN, data_imprint) + return update_container(new_group_node, data_imprint) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index ba2de3d05d..ce0a1615f1 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -14,7 +14,8 @@ from openpype.hosts.nuke.api.lib import ( maintained_selection, create_backdrop, get_avalon_knob_data, - set_avalon_knob_data + set_avalon_knob_data, + swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, @@ -47,7 +48,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerised nuke node object + nuke node: containerized nuke node object """ # get main variables @@ -85,17 +86,17 @@ class LoadGizmoInputProcess(load.LoaderPlugin): # add group from nk nuke.nodePaste(file) - GN = nuke.selectedNode() + group_node = nuke.selectedNode() - GN["name"].setValue(object_name) + group_node["name"].setValue(object_name) # try to place it under Viewer1 - if not self.connect_active_viewer(GN): - nuke.delete(GN) + if not self.connect_active_viewer(group_node): + nuke.delete(group_node) return return containerise( - node=GN, + node=group_node, name=name, namespace=namespace, context=context, @@ -117,7 +118,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + group_node = nuke.toNode(container['objectName']) file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -142,22 +143,24 @@ class LoadGizmoInputProcess(load.LoaderPlugin): for k in add_keys: data_imprint.update({k: version_data[k]}) + # capture pipeline metadata + avalon_data = get_avalon_knob_data(group_node) + # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() - with maintained_selection(): - xpos = GN.xpos() - ypos = GN.ypos() - avalon_data = get_avalon_knob_data(GN) - nuke.delete(GN) - # add group from nk + with maintained_selection([group_node]): + # insert nuke script to the script nuke.nodePaste(file) - - GN = nuke.selectedNode() - set_avalon_knob_data(GN, avalon_data) - GN.setXYpos(xpos, ypos) - GN["name"].setValue(object_name) + # convert imported to selected node + new_group_node = nuke.selectedNode() + # swap nodes with maintained connections + with swap_node_with_dependency( + group_node, new_group_node) as node_name: + new_group_node["name"].setValue(node_name) + # set updated pipeline metadata + set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -168,11 +171,11 @@ class LoadGizmoInputProcess(load.LoaderPlugin): color_value = self.node_color else: color_value = "0xd88467ff" - GN["tile_color"].setValue(int(color_value, 16)) + new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) - return update_container(GN, data_imprint) + return update_container(new_group_node, data_imprint) def connect_active_viewer(self, group_node): """ From 247451bb5871d1287dd20362081a2c09637f5688 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 11:45:55 +0200 Subject: [PATCH 0613/1224] thumbnail extractor as last extractor --- openpype/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index de101ac7ac..0ddbb3f40b 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -17,7 +17,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): """Create jpg thumbnail from sequence using ffmpeg""" label = "Extract Thumbnail" - order = pyblish.api.ExtractorOrder + order = pyblish.api.ExtractorOrder + 0.49 families = [ "imagesequence", "render", "render2d", "prerender", "source", "clip", "take", "online", "image" From 908b8e3fb69a2347d6b6208f32c546dc55ba061a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Oct 2023 12:22:01 +0200 Subject: [PATCH 0614/1224] Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator --- openpype/hosts/maya/api/plugin.py | 3 +- .../hosts/maya/plugins/load/load_reference.py | 100 +++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3b54954c8a..07167a9a32 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -771,7 +771,8 @@ class ReferenceLoader(Loader): "ma": "mayaAscii", "mb": "mayaBinary", "abc": "Alembic", - "fbx": "FBX" + "fbx": "FBX", + "usd": "USD Import" }.get(representation["name"]) assert file_type, "Unsupported representation: %s" % representation diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 4b704fa706..0d7f08d3c3 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,7 +1,9 @@ import os import difflib import contextlib + from maya import cmds +import qargparse from openpype.settings import get_project_settings import openpype.hosts.maya.api.plugin @@ -128,6 +130,12 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if not attach_to_root: group_name = namespace + kwargs = {} + if "file_options" in options: + kwargs["options"] = options["file_options"] + if "file_type" in options: + kwargs["type"] = options["file_type"] + path = self.filepath_from_context(context) with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) @@ -139,7 +147,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): reference=True, returnNewNodes=True, groupReference=attach_to_root, - groupName=group_name) + groupName=group_name, + **kwargs) shapes = cmds.ls(nodes, shapes=True, long=True) @@ -251,3 +260,92 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): else: self.log.warning("This version of Maya does not support locking of" " transforms of cameras.") + + +class MayaUSDReferenceLoader(ReferenceLoader): + """Reference USD file to native Maya nodes using MayaUSDImport reference""" + + families = ["usd"] + representations = ["usd"] + extensions = {"usd", "usda", "usdc"} + + options = ReferenceLoader.options + [ + qargparse.Boolean( + "readAnimData", + label="Load anim data", + default=True, + help="Load animation data from USD file" + ), + qargparse.Boolean( + "useAsAnimationCache", + label="Use as animation cache", + default=True, + help=( + "Imports geometry prims with time-sampled point data using a " + "point-based deformer that references the imported " + "USD file.\n" + "This provides better import and playback performance when " + "importing time-sampled geometry from USD, and should " + "reduce the weight of the resulting Maya scene." + ) + ), + qargparse.Boolean( + "importInstances", + label="Import instances", + default=True, + help=( + "Import USD instanced geometries as Maya instanced shapes. " + "Will flatten the scene otherwise." + ) + ), + qargparse.String( + "primPath", + label="Prim Path", + default="/", + help=( + "Name of the USD scope where traversing will begin.\n" + "The prim at the specified primPath (including the prim) will " + "be imported.\n" + "Specifying the pseudo-root (/) means you want " + "to import everything in the file.\n" + "If the passed prim path is empty, it will first try to " + "import the defaultPrim for the rootLayer if it exists.\n" + "Otherwise, it will behave as if the pseudo-root was passed " + "in." + ) + ) + ] + + file_type = "USD Import" + + def process_reference(self, context, name, namespace, options): + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + def bool_option(key, default): + # Shorthand for getting optional boolean file option from options + value = int(bool(options.get(key, default))) + return "{}={}".format(key, value) + + def string_option(key, default): + # Shorthand for getting optional string file option from options + value = str(options.get(key, default)) + return "{}={}".format(key, value) + + options["file_options"] = ";".join([ + string_option("primPath", default="/"), + bool_option("importInstances", default=True), + bool_option("useAsAnimationCache", default=True), + bool_option("readAnimData", default=True), + # TODO: Expose more parameters + # "preferredMaterial=none", + # "importRelativeTextures=Automatic", + # "useCustomFrameRange=0", + # "startTime=0", + # "endTime=0", + # "importUSDZTextures=0" + ]) + options["file_type"] = self.file_type + + return super(MayaUSDReferenceLoader, self).process_reference( + context, name, namespace, options + ) From a1a4898a731547db13a884f40df5640b48d65b6a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 17 Oct 2023 18:31:22 +0800 Subject: [PATCH 0615/1224] updated the publish review animation for 2023 and 2024 3dsMax respectively --- openpype/hosts/max/api/lib.py | 159 ++++++++++++++---- .../hosts/max/plugins/create/create_review.py | 8 +- .../max/plugins/publish/collect_review.py | 11 +- .../publish/extract_review_animation.py | 24 +-- .../publish/validate_resolution_setting.py | 2 +- 5 files changed, 143 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 26ca5ed1d8..6817160ce7 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" +import os import contextlib import logging import json @@ -323,6 +324,11 @@ def is_headless(): @contextlib.contextmanager def viewport_setup_updated(camera): + """Function to set viewport camera during context + ***For 3dsMax 2024+ + Args: + camera (str): viewport camera for review render + """ original = rt.viewport.getCamera() has_autoplay = rt.preferences.playPreviewWhenDone if not original: @@ -340,32 +346,59 @@ def viewport_setup_updated(camera): @contextlib.contextmanager -def viewport_setup(viewport_setting, visual_style, camera): - """Function to set visual style options +def viewport_setup(instance, viewport_setting, camera): + """Function to set camera and other viewport options + during context + ****For Max Version < 2024 Args: - visual_style (str): visual style for active viewport + instance (str): instance + viewport_setting (str): active viewport setting + camera (str): viewport camera - Returns: - list: the argument which can set visual style """ original = rt.viewport.getCamera() + has_vp_btn = rt.ViewportButtonMgr.EnableButtons has_autoplay = rt.preferences.playPreviewWhenDone if not original: # if there is no original camera # use the current camera as original original = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) - current_setting = viewport_setting.VisualStyleMode + + current_visualStyle = viewport_setting.VisualStyleMode + current_visualPreset = viewport_setting.ViewportPreset + current_useTexture = viewport_setting.UseTextureEnabled + orig_vp_grid = rt.viewport.getGridVisibility(1) + orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() + + visualStyle = instance.data.get("visualStyleMode") + viewportPreset = instance.data.get("viewportPreset") + useTexture = instance.data.get("vpTexture") + has_grid_viewport = instance.data.get("dspGrid") + bkg_color_viewport = instance.data.get("dspBkg") + try: rt.viewport.setCamera(review_camera) - viewport_setting.VisualStyleMode = rt.Name( - visual_style) + rt.viewport.setGridVisibility(1, has_grid_viewport) rt.preferences.playPreviewWhenDone = False + rt.ViewportButtonMgr.EnableButtons = False + rt.viewport.EnableSolidBackgroundColorMode( + bkg_color_viewport) + viewport_setting.VisualStyleMode = rt.Name( + visualStyle) + viewport_setting.ViewportPreset = rt.Name( + viewportPreset) + viewport_setting.UseTextureEnabled = useTexture yield finally: rt.viewport.setCamera(original) - viewport_setting.VisualStyleMode = current_setting + rt.viewport.setGridVisibility(1, orig_vp_grid) + rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) + viewport_setting.VisualStyleMode = current_visualStyle + viewport_setting.ViewportPreset = current_visualPreset + viewport_setting.UseTextureEnabled = current_useTexture + rt.ViewportButtonMgr.EnableButtons = has_vp_btn rt.preferences.playPreviewWhenDone = has_autoplay @@ -532,9 +565,10 @@ def get_plugins() -> list: return plugin_info_list -def set_preview_arg(instance, filepath, - start, end, fps): +def publish_review_animation(instance, filepath, + start, end, fps): """Function to set up preview arguments in MaxScript. + ****For 3dsMax 2024+ Args: instance (str): instance @@ -561,31 +595,88 @@ def set_preview_arg(instance, filepath, enabled = instance.data.get(key) if enabled: job_args.append(f"{key}:{enabled}") - if get_max_version() >= 2024: - visual_style_preset = instance.data.get("visualStyleMode") - if visual_style_preset == "Realistic": - visual_style_preset = "defaultshading" - else: - visual_style_preset = visual_style_preset.lower() - # new argument exposed for Max 2024 for visual style - visual_style_option = f"vpStyle:#{visual_style_preset}" - job_args.append(visual_style_option) - # new argument for pre-view preset exposed in Max 2024 - preview_preset = instance.data.get("viewportPreset") - if preview_preset == "Quality": - preview_preset = "highquality" - elif preview_preset == "Customize": - preview_preset = "userdefined" - else: - preview_preset = preview_preset.lower() - preview_preset_option = f"vpPreset:#{visual_style_preset}" - job_args.append(preview_preset_option) - viewport_texture = instance.data.get("vpTexture", True) - if viewport_texture: - viewport_texture_option = f"vpTexture:{viewport_texture}" - job_args.append(viewport_texture_option) + + visual_style_preset = instance.data.get("visualStyleMode") + if visual_style_preset == "Realistic": + visual_style_preset = "defaultshading" + else: + visual_style_preset = visual_style_preset.lower() + # new argument exposed for Max 2024 for visual style + visual_style_option = f"vpStyle:#{visual_style_preset}" + job_args.append(visual_style_option) + # new argument for pre-view preset exposed in Max 2024 + preview_preset = instance.data.get("viewportPreset") + if preview_preset == "Quality": + preview_preset = "highquality" + elif preview_preset == "Customize": + preview_preset = "userdefined" + else: + preview_preset = preview_preset.lower() + preview_preset_option = f"vpPreset:#{preview_preset}" + job_args.append(preview_preset_option) + viewport_texture = instance.data.get("vpTexture", True) + if viewport_texture: + viewport_texture_option = f"vpTexture:{viewport_texture}" + job_args.append(viewport_texture_option) job_str = " ".join(job_args) log.debug(job_str) return job_str + +def publish_preview_sequences(staging_dir, filename, + startFrame, endFrame, ext): + """publish preview animation by creating bitmaps + ***For 3dsMax Version <2024 + + Args: + staging_dir (str): staging directory + filename (str): filename + startFrame (int): start frame + endFrame (int): end frame + ext (str): image extension + """ + # get the screenshot + rt.forceCompleteRedraw() + rt.enableSceneRedraw() + res_width = rt.renderWidth + res_height = rt.renderHeight + + viewportRatio = float(res_width / res_height) + + for i in range(startFrame, endFrame + 1): + rt.sliderTime = i + fname = "{}.{:04}.{}".format(filename, i, ext) + filepath = os.path.join(staging_dir, fname) + filepath = filepath.replace("\\", "/") + preview_res = rt.bitmap( + res_width, res_height, filename=filepath) + dib = rt.gw.getViewportDib() + dib_width = float(dib.width) + dib_height = float(dib.height) + renderRatio = float(dib_width / dib_height) + if viewportRatio <= renderRatio: + heightCrop = (dib_width / renderRatio) + topEdge = int((dib_height - heightCrop) / 2.0) + tempImage_bmp = rt.bitmap(dib_width, heightCrop) + src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) + else: + widthCrop = dib_height * renderRatio + leftEdge = int((dib_width - widthCrop) / 2.0) + tempImage_bmp = rt.bitmap(widthCrop, dib_height) + src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height) + rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) + + # copy the bitmap and close it + rt.copy(tempImage_bmp, preview_res) + rt.close(tempImage_bmp) + + rt.save(preview_res) + rt.close(preview_res) + + rt.close(dib) + + if rt.keyboard.escPressed: + rt.exit() + # clean up the cache + rt.gc() diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index e654783a33..ea56123c79 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -20,7 +20,8 @@ class CreateReview(plugin.MaxCreator): "keepImages", "percentSize", "visualStyleMode", - "viewportPreset"]: + "viewportPreset", + "vpTexture"]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] @@ -66,7 +67,10 @@ class CreateReview(plugin.MaxCreator): EnumDef("viewportPreset", preview_preset_enum, default="Quality", - label="Pre-View Preset") + label="Pre-View Preset"), + BoolDef("vpTexture", + label="Viewport Texture", + default=False) ] def get_pre_create_attr_defs(self): diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 8b9a777c63..9ab1d6f3a8 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -34,6 +34,7 @@ class CollectReview(pyblish.api.InstancePlugin, "percentSize": creator_attrs["percentSize"], "visualStyleMode": creator_attrs["visualStyleMode"], "viewportPreset": creator_attrs["viewportPreset"], + "vpTexture": creator_attrs["vpTexture"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], @@ -59,7 +60,6 @@ class CollectReview(pyblish.api.InstancePlugin, instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform - instance.data["vpTexture"] = attr_values.get("vpTexture") # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -72,14 +72,7 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - additional_attrs = [] - if int(get_max_version()) >= 2024: - additional_attrs.append( - BoolDef("vpTexture", - label="Viewport Texture", - default=True), - ) - return additional_attrs + [ + return [ BoolDef("dspGeometry", label="Geometry", default=True), diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index da3f4155c1..a77f6213fa 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -6,7 +6,8 @@ from openpype.hosts.max.api.lib import ( viewport_setup_updated, viewport_setup, get_max_version, - set_preview_arg + publish_review_animation, + publish_preview_sequences ) @@ -37,22 +38,15 @@ class ExtractReviewAnimation(publish.Extractor): " '%s' to '%s'" % (filename, staging_dir)) review_camera = instance.data["review_camera"] - if int(get_max_version()) >= 2024: - with viewport_setup_updated(review_camera): - preview_arg = set_preview_arg( - instance, filepath, start, end, fps) - rt.execute(preview_arg) - else: - visual_style_preset = instance.data.get("visualStyleMode") + if int(get_max_version()) < 2024: nitrousGraphicMgr = rt.NitrousGraphicsManager viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - with viewport_setup( - viewport_setting, - visual_style_preset, - review_camera): - viewport_setting.VisualStyleMode = rt.Name( - visual_style_preset) - preview_arg = set_preview_arg( + with viewport_setup(instance, viewport_setting, review_camera): + publish_preview_sequences( + staging_dir, instance.name, start, end, ext) + else: + with viewport_setup_updated(review_camera): + preview_arg = publish_review_animation( instance, filepath, start, end, fps) rt.execute(preview_arg) diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py index 5ac41b10a0..969db0da2d 100644 --- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py +++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py @@ -12,7 +12,7 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, """Validate the resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 - families = ["maxrender"] + families = ["maxrender", "review"] hosts = ["max"] label = "Validate Resolution Setting" optional = True From fcbe4616018c780102bd752f43a83956dcae6961 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 17 Oct 2023 18:34:50 +0800 Subject: [PATCH 0616/1224] hound fix for the last commit --- openpype/hosts/max/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 6817160ce7..69dfd600a5 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -624,6 +624,7 @@ def publish_review_animation(instance, filepath, return job_str + def publish_preview_sequences(staging_dir, filename, startFrame, endFrame, ext): """publish preview animation by creating bitmaps From 7c035a157e801869ead6c56193f9aa34a323dfe8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 12:52:46 +0200 Subject: [PATCH 0617/1224] 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 From ab2241aebb62de3489c13458f2d118b5e49e9886 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 17 Oct 2023 19:11:23 +0800 Subject: [PATCH 0618/1224] fix the viewport setting issue when the first frame is flickering with different setups --- openpype/hosts/max/api/lib.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 69dfd600a5..736b0fb544 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -385,11 +385,14 @@ def viewport_setup(instance, viewport_setting, camera): rt.ViewportButtonMgr.EnableButtons = False rt.viewport.EnableSolidBackgroundColorMode( bkg_color_viewport) - viewport_setting.VisualStyleMode = rt.Name( - visualStyle) - viewport_setting.ViewportPreset = rt.Name( - viewportPreset) - viewport_setting.UseTextureEnabled = useTexture + if visualStyle != current_visualStyle: + viewport_setting.VisualStyleMode = rt.Name( + visualStyle) + elif viewportPreset != current_visualPreset: + viewport_setting.ViewportPreset = rt.Name( + viewportPreset) + elif useTexture != current_useTexture: + viewport_setting.UseTextureEnabled = useTexture yield finally: rt.viewport.setCamera(original) @@ -402,6 +405,7 @@ def viewport_setup(instance, viewport_setting, camera): rt.preferences.playPreviewWhenDone = has_autoplay + def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ From 27106bcc724b8db6bdf739d33d9fd9700dfc2f22 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 14:12:57 +0200 Subject: [PATCH 0619/1224] moved show and publish logic to publisher window --- openpype/hosts/houdini/api/lib.py | 17 +++++-------- openpype/tools/publisher/window.py | 41 +++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 6fa8b02735..8863570966 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -856,22 +856,19 @@ def update_houdini_vars_context_dialog(): dialog.show() -def publisher_show_and_publish(comment=""): - """Open publisher window and trigger publishing action.""" +def publisher_show_and_publish(comment=None): + """Open publisher window and trigger publishing action. + + Args: + comment (Optional[str]): Comment to set in publisher window. + """ main_window = get_main_window() publisher_window = get_tool_by_name( tool_name="publisher", parent=main_window, - reset_on_show=False ) - - publisher_window.set_current_tab("publish") - publisher_window.make_sure_is_visible() - publisher_window.reset_on_show = False - publisher_window.set_comment_input_text(comment) - publisher_window.reset() - publisher_window.click_publish() + publisher_window.show_and_pubish(comment) def find_rop_input_dependencies(input_tuple): diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 9214c0a43f..af6d7371b1 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -388,20 +388,43 @@ class PublisherWindow(QtWidgets.QDialog): def controller(self): return self._controller - @property - def reset_on_show(self): - return self._reset_on_show + def show_and_publish(self, comment=None): + """Show the window and start publishing. - @reset_on_show.setter - def reset_on_show(self, value): - self._reset_on_show = value + The method will reset controller and start the publishing afterwards. - def set_comment_input_text(self, text=""): - self._comment_input.setText(text) + Todos: + Move validations from '_on_publish_clicked' and change of + 'comment' value in controller to controller so it can be + simplified. - def click_publish(self): + Args: + comment (Optional[str]): Comment to be set to publish. + If is set to 'None' a comment is not changed at all. + """ + + if comment is not None: + self.set_comment(comment) + self._reset_on_show = False + self.make_sure_is_visible() + # Reset controller + self._controller.reset() + # Fake publish click to trigger save validation and propagate + # comment to controller self._on_publish_clicked() + def set_comment(self, comment): + """Change comment text. + + Todos: + Be able to set the comment via controller. + + Args: + comment (str): Comment text. + """ + + self._comment_input.setText(comment) + def make_sure_is_visible(self): if self._window_is_visible: self.setWindowState(QtCore.Qt.WindowActive) From 0f9c30378ec49babc463b20e46bec07dca56020b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 14:26:14 +0200 Subject: [PATCH 0620/1224] revert 'get_publisher_tool' and 'show_publisher_tool' arguments --- openpype/tools/utils/host_tools.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 5f538fe45b..cc20774349 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -286,9 +286,7 @@ class HostToolsHelper: dialog.activateWindow() dialog.showNormal() - def get_publisher_tool( - self, parent=None, controller=None, reset_on_show=None - ): + def get_publisher_tool(self, parent=None, controller=None): """Create, cache and return publisher window.""" if self._publisher_tool is None: @@ -299,18 +297,15 @@ class HostToolsHelper: publisher_window = PublisherWindow( controller=controller, - parent=parent or self._parent, - reset_on_show=reset_on_show + parent=parent or self._parent ) self._publisher_tool = publisher_window return self._publisher_tool - def show_publisher_tool( - self, parent=None, controller=None, reset_on_show=None, tab=None - ): + def show_publisher_tool(self, parent=None, controller=None, tab=None): with qt_app_context(): - window = self.get_publisher_tool(parent, controller, reset_on_show) + window = self.get_publisher_tool(parent, controller) if tab: window.set_current_tab(tab) window.make_sure_is_visible() From e6ac57b35fa4ea51bfcde3e844660e60b95cc97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 17 Oct 2023 15:11:45 +0200 Subject: [PATCH 0621/1224] Update openpype/hosts/houdini/api/lib.py Co-authored-by: Roy Nieterau --- 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 8863570966..e4b9d70d57 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -868,7 +868,7 @@ def publisher_show_and_publish(comment=None): tool_name="publisher", parent=main_window, ) - publisher_window.show_and_pubish(comment) + publisher_window.show_and_publish(comment) def find_rop_input_dependencies(input_tuple): From b1ea0b099dd13e13515d3a4f32b2830702496488 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 15:32:42 +0200 Subject: [PATCH 0622/1224] merging if conditions for handles exclusion also updating docstring --- openpype/hosts/resolve/api/lib.py | 5 +++-- openpype/hosts/resolve/api/plugin.py | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 22be929412..2d91609679 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -258,9 +258,10 @@ def create_timeline_item( Args: media_pool_item (resolve.MediaPoolItem): resolve's object + source_start (int): media source input frame (sequence frame) + source_end (int): media source output frame (sequence frame) + timeline_in (int): timeline input frame (sequence frame) timeline (resolve.Timeline)[optional]: resolve's object - source_start (int)[optional]: media source input frame (sequence frame) - source_end (int)[optional]: media source output frame (sequence frame) Returns: object: resolve.TimelineItem diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 1c817d8e0d..314d066890 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -418,12 +418,11 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) - if _clip_property("Type") == "Video": - source_in += handle_start - source_out -= handle_end - # include handles - if not self.with_handles: + if ( + not self.with_handles + or _clip_property("Type") == "Video" + ): source_in += handle_start source_out -= handle_end From b66a679ee2517849f173718eede45cf62585c4e1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 16:19:34 +0200 Subject: [PATCH 0623/1224] adding docstring for maintained selection --- openpype/hosts/nuke/api/lib.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 0a5f772346..bb8fbd01c4 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2800,6 +2800,13 @@ def find_free_space_to_paste_nodes( def maintained_selection(exclude_nodes=None): """Maintain selection during context + Maintain selection during context and unselect + all nodes after context is done. + + Arguments: + exclude_nodes (list[nuke.Node]): list of nodes to be unselected + before context is done + Example: >>> with maintained_selection(): ... node["selected"].setValue(True) From 13159c48890e24734da1390edd87d578aa98f640 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 17 Oct 2023 17:29:27 +0300 Subject: [PATCH 0624/1224] Jakub comments --- openpype/hosts/houdini/api/lib.py | 8 ++------ openpype/hosts/houdini/api/plugin.py | 5 +++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index e4b9d70d57..f258dda36e 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -924,11 +924,7 @@ def self_publish(): for instance in context.instances: node_path = instance.data.get("instance_node") - if not node_path: - continue - - active = node_path in inputs_paths - instance["active"] = active + instance["active"] = node_path and node_path in inputs_paths context.save_changes() @@ -941,7 +937,7 @@ def add_self_publish_button(node): label = os.environ.get("AVALON_LABEL") or "OpenPype" button_parm = hou.ButtonParmTemplate( - "{}_publish".format(label.lower()), + "ayon_self_publish", "{} Publish".format(label), script_callback="from openpype.hosts.houdini.api.lib import " "self_publish; self_publish()", diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 5102b64644..d79ccc71bd 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -325,8 +325,9 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Method called on initialization of plugin to apply settings.""" # Apply General Settings - self.add_publish_button = \ - project_settings["houdini"]["general"]["add_self_publish_button"] + houdini_general_settings = project_settings["houdini"]["general"] + self.add_publish_button = houdini_general_settings.get( + "add_self_publish_button", False) # Apply Creator Settings settings_name = self.settings_name From f214751be375016d4464b1faf4e427c1851f36a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 16:29:55 +0200 Subject: [PATCH 0625/1224] modules can be loaded in dev mode correctly --- openpype/modules/base.py | 87 ++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index a3c21718b9..e5741728d9 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -37,7 +37,6 @@ from openpype.lib import ( import_filepath, import_module_from_dirpath, ) -from openpype.lib.openpype_version import is_staging_enabled from .interfaces import ( OpenPypeInterface, @@ -317,21 +316,10 @@ def load_modules(force=False): time.sleep(0.1) -def _get_ayon_addons_information(): - """Receive information about addons to use from server. - - Todos: - Actually ask server for the information. - Allow project name as optional argument to be able to query information - about used addons for specific project. - Returns: - List[Dict[str, Any]]: List of addon information to use. - """ - - output = [] +def _get_ayon_bundle_data(): bundle_name = os.getenv("AYON_BUNDLE_NAME") bundles = ayon_api.get_bundles()["bundles"] - final_bundle = next( + return next( ( bundle for bundle in bundles @@ -339,10 +327,32 @@ def _get_ayon_addons_information(): ), None ) - if final_bundle is None: - return output - bundle_addons = final_bundle["addons"] + +def _is_dev_mode_enabled(): + """Dev mode is enabled in AYON. + + Returns: + bool: True if dev mode is enabled. + """ + + return os.getenv("AYON_DEV_MODE") == "1" + + +def _get_ayon_addons_information(bundle_info): + """Receive information about addons to use from server. + + Todos: + Actually ask server for the information. + Allow project name as optional argument to be able to query information + about used addons for specific project. + + Returns: + List[Dict[str, Any]]: List of addon information to use. + """ + + output = [] + bundle_addons = bundle_info["addons"] addons = ayon_api.get_addons_info()["addons"] for addon in addons: name = addon["name"] @@ -378,31 +388,56 @@ def _load_ayon_addons(openpype_modules, modules_key, log): v3_addons_to_skip = [] - addons_info = _get_ayon_addons_information() + bundle_info = _get_ayon_bundle_data() + addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: return v3_addons_to_skip + addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: addons_dir = os.path.join( appdirs.user_data_dir("AYON", "Ynput"), "addons" ) - if not os.path.exists(addons_dir): + + dev_mode_enabled = _is_dev_mode_enabled() + dev_addons_info = {} + if dev_mode_enabled: + # Get dev addons info only when dev mode is enabled + dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) + + addons_dir_exists = os.path.exists(addons_dir) + if not addons_dir_exists: log.warning("Addons directory does not exists. Path \"{}\"".format( addons_dir )) - return v3_addons_to_skip for addon_info in addons_info: addon_name = addon_info["name"] addon_version = addon_info["version"] - folder_name = "{}_{}".format(addon_name, addon_version) - addon_dir = os.path.join(addons_dir, folder_name) - if not os.path.exists(addon_dir): - log.debug(( - "No localized client code found for addon {} {}." - ).format(addon_name, addon_version)) + dev_addon_info = dev_addons_info.get(addon_name, {}) + use_dev_path = dev_addon_info.get("enabled", False) + + addon_dir = None + if use_dev_path: + addon_dir = dev_addon_info["path"] + if not addon_dir or not os.path.exists(addon_dir): + log.warning(( + "Dev addon {} {} path does not exists. Path \"{}\"" + ).format(addon_name, addon_version, addon_dir)) + continue + + elif addons_dir_exists: + folder_name = "{}_{}".format(addon_name, addon_version) + addon_dir = os.path.join(addons_dir, folder_name) + if not os.path.exists(addon_dir): + log.debug(( + "No localized client code found for addon {} {}." + ).format(addon_name, addon_version)) + continue + + if not addon_dir: continue sys.path.insert(0, addon_dir) From 0b02a97e5fabbf34136c6ebb5957c4930aea971c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 17 Oct 2023 17:40:45 +0300 Subject: [PATCH 0626/1224] bump houdini addon version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" From 74acdd63eee751eecfe2d92933d3d3752d89b4ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 16:41:27 +0200 Subject: [PATCH 0627/1224] change env variable key --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index e5741728d9..355fee0e0a 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -336,7 +336,7 @@ def _is_dev_mode_enabled(): bool: True if dev mode is enabled. """ - return os.getenv("AYON_DEV_MODE") == "1" + return os.getenv("AYON_USE_DEV") == "1" def _get_ayon_addons_information(bundle_info): From a32e3996956b78db172f3a08989650fdeff9e58d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 17:06:21 +0200 Subject: [PATCH 0628/1224] use dev variant in dev mode --- openpype/settings/ayon_settings.py | 40 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 3ccb18111a..eb64480dc3 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -290,6 +290,16 @@ def _convert_modules_system( modules_settings[key] = value +def is_dev_mode_enabled(): + """Dev mode is enabled in AYON. + + Returns: + bool: True if dev mode is enabled. + """ + + return os.getenv("AYON_USE_DEV") == "1" + + def convert_system_settings(ayon_settings, default_settings, addon_versions): default_settings = copy.deepcopy(default_settings) output = { @@ -1400,15 +1410,39 @@ class _AyonSettingsCache: if _AyonSettingsCache.variant is None: from openpype.lib.openpype_version import is_staging_enabled - _AyonSettingsCache.variant = ( - "staging" if is_staging_enabled() else "production" - ) + variant = "production" + if is_dev_mode_enabled(): + variant = cls._get_dev_mode_settings_variant() + elif is_staging_enabled(): + variant = "staging" + _AyonSettingsCache.variant = variant return _AyonSettingsCache.variant @classmethod def _get_bundle_name(cls): return os.environ["AYON_BUNDLE_NAME"] + @classmethod + def _get_dev_mode_settings_variant(cls): + """Develop mode settings variant. + + Returns: + str: Name of settings variant. + """ + + bundles = ayon_api.get_bundles() + user = ayon_api.get_user() + username = user["name"] + for bundle in bundles: + if ( + bundle.get("isDev") + and bundle.get("activeUser") == username + ): + return bundle["name"] + # Return fake variant - distribution logic will tell user that he does not + # have set any dev bundle + return "dev" + @classmethod def get_value_by_project(cls, project_name): cache_item = _AyonSettingsCache.cache_by_project_name[project_name] From 2088c7d7e6109b589724a13905547cbb1d6aa28f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 17 Oct 2023 17:06:38 +0200 Subject: [PATCH 0629/1224] use 'is_dev_mode_enabled' from 'ayon_'settings' --- openpype/modules/base.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 355fee0e0a..6f3e4566f3 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -31,6 +31,7 @@ from openpype.settings.lib import ( get_studio_system_settings_overrides, load_json_file ) +from openpype.settings.ayon_settings import is_dev_mode_enabled from openpype.lib import ( Logger, @@ -329,16 +330,6 @@ def _get_ayon_bundle_data(): ) -def _is_dev_mode_enabled(): - """Dev mode is enabled in AYON. - - Returns: - bool: True if dev mode is enabled. - """ - - return os.getenv("AYON_USE_DEV") == "1" - - def _get_ayon_addons_information(bundle_info): """Receive information about addons to use from server. @@ -400,7 +391,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): "addons" ) - dev_mode_enabled = _is_dev_mode_enabled() + dev_mode_enabled = is_dev_mode_enabled() dev_addons_info = {} if dev_mode_enabled: # Get dev addons info only when dev mode is enabled From 9e61d7e371d54a500cc1c67dd3c560d8cfcf98c1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 17:39:10 +0200 Subject: [PATCH 0630/1224] removing debug printing --- openpype/hosts/resolve/api/lib.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index ca79dd6e87..dfe4608a46 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -292,10 +292,6 @@ def create_timeline_item( "recordFrame": timeline_in, } - print("clip_data", "_" * 50) - print(media_pool_item.GetName()) - print(clip_data) - # add to timeline media_pool.AppendToTimeline([clip_data]) @@ -511,7 +507,7 @@ def imprint(timeline_item, data=None): Arguments: timeline_item (hiero.core.TrackItem): hiero track item object - data (dict): Any data which needst to be imprinted + data (dict): Any data which needs to be imprinted Examples: data = { From 3bbc3d0489db4ea0d785416e6459c79fee34701e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 17 Oct 2023 17:42:46 +0200 Subject: [PATCH 0631/1224] Update openpype/hosts/resolve/api/lib.py Co-authored-by: Roy Nieterau --- 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 dfe4608a46..20636f299b 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -251,10 +251,10 @@ def get_media_pool_item(filepath, root: object = None) -> object: def create_timeline_item( media_pool_item: object, - source_start: int, - source_end: int, - timeline_in: int, - timeline: object = None + timeline: object = None, + timeline_in: int = None, + source_start: int = None, + source_end: int = None, ) -> object: """ Add media pool item to current or defined timeline. From 435408dc142cb934424333257f8092db37974b01 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 17 Oct 2023 17:47:23 +0200 Subject: [PATCH 0632/1224] create_timeline_item with backward comapatibility --- openpype/hosts/resolve/api/lib.py | 30 ++++++++++++++++++---------- openpype/hosts/resolve/api/plugin.py | 4 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 20636f299b..6f84d921e0 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -261,10 +261,10 @@ def create_timeline_item( Args: media_pool_item (resolve.MediaPoolItem): resolve's object - source_start (int): media source input frame (sequence frame) - source_end (int): media source output frame (sequence frame) - timeline_in (int): timeline input frame (sequence frame) - timeline (resolve.Timeline)[optional]: resolve's object + timeline (Optional[resolve.Timeline]): resolve's object + timeline_in (Optional[int]): timeline input frame (sequence frame) + source_start (Optional[int]): media source input frame (sequence frame) + source_end (Optional[int]): media source output frame (sequence frame) Returns: object: resolve.TimelineItem @@ -277,21 +277,29 @@ def create_timeline_item( timeline = timeline or get_current_timeline() # timing variables - fps = project.GetSetting("timelineFrameRate") - duration = source_end - source_start - timecode_in = frames_to_timecode(timeline_in, fps) - timecode_out = frames_to_timecode(timeline_in + duration, fps) + if all([timeline_in, source_start, source_end]): + fps = project.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + else: + timecode_in = None + timecode_out = None # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data clip_data = { "mediaPoolItem": media_pool_item, - "startFrame": source_start, - "endFrame": source_end, - "recordFrame": timeline_in, } + if source_start: + clip_data["startFrame"] = source_start + if source_end: + clip_data["endFrame"] = source_end + if timecode_in: + clip_data["recordFrame"] = timecode_in + # add to timeline media_pool.AppendToTimeline([clip_data]) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 95f2fb2281..c88ed762ab 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -434,10 +434,10 @@ class ClipLoader: # make track item from source in bin as item timeline_item = lib.create_timeline_item( media_pool_item, + self.active_timeline, + timeline_in, source_in, source_out, - timeline_in, - self.active_timeline, ) print("Loading clips: `{}`".format(self.data["clip_name"])) From af9dbe5af2688c018ebdae9602beb0b3c0b89888 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 18 Oct 2023 03:25:10 +0000 Subject: [PATCH 0633/1224] [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 f98d4c1cf5..6f740d0c78 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.3-nightly.1" +__version__ = "3.17.3-nightly.2" From 1be774230b05d895d595a96993e577646c1d1207 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Oct 2023 03:25:51 +0000 Subject: [PATCH 0634/1224] 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 dba39ac36d..2849a4951a 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.2 - 3.17.3-nightly.1 - 3.17.2 - 3.17.2-nightly.4 @@ -134,7 +135,6 @@ body: - 3.14.11-nightly.4 - 3.14.11-nightly.3 - 3.14.11-nightly.2 - - 3.14.11-nightly.1 validations: required: true - type: dropdown From 3461cbed58efeb3c901e66b7c677002543021f03 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 12:17:58 +0800 Subject: [PATCH 0635/1224] use originalbasename entirely for the publishing tycache --- .../hosts/max/plugins/publish/collect_tycache_attributes.py | 2 +- openpype/settings/defaults/project_anatomy/templates.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index 56cf6614e2..fa27a9a9d6 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -60,7 +60,7 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", "tycacheChanShape", "tycacheChanMatID", "tycacheChanMapping", - "tycacheChanMaterials", "tycacheCreateObject", + "tycacheChanMaterials", "tycacheCreateObjectIfNotCreated"] return [ EnumDef("all_tyc_attrs", diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 5694693c97..5766a09100 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -55,7 +55,7 @@ }, "tycache": { "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", - "file": "{originalBasename}<_{@frame}>.{ext}", + "file": "{originalBasename}.{ext}", "path": "{@folder}/{@file}" }, "source": { From 863ed821cad4a8544e6d37c272b70acdf852683d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 10:04:45 +0200 Subject: [PATCH 0636/1224] change '_reset_on_first_show' to 'False' on show and publish --- openpype/tools/publisher/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index af6d7371b1..312cf1dd5c 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -403,9 +403,11 @@ class PublisherWindow(QtWidgets.QDialog): If is set to 'None' a comment is not changed at all. """ + self._reset_on_show = False + self._reset_on_first_show = False + if comment is not None: self.set_comment(comment) - self._reset_on_show = False self.make_sure_is_visible() # Reset controller self._controller.reset() From 68f7826cf610b8160cb8ce21bc764f1964eb4559 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 10:16:17 +0200 Subject: [PATCH 0637/1224] updated 'ayon_api' to '0.5.1' --- .../vendor/python/common/ayon_api/_api.py | 14 +- .../python/common/ayon_api/graphql_queries.py | 5 + .../python/common/ayon_api/server_api.py | 177 ++++++++++++------ .../vendor/python/common/ayon_api/utils.py | 83 ++++++-- .../vendor/python/common/ayon_api/version.py | 2 +- 5 files changed, 194 insertions(+), 87 deletions(-) diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 22e137d6e5..9f89d3d59e 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -602,12 +602,12 @@ def delete_installer(*args, **kwargs): def download_installer(*args, **kwargs): con = get_server_api_connection() - con.download_installer(*args, **kwargs) + return con.download_installer(*args, **kwargs) def upload_installer(*args, **kwargs): con = get_server_api_connection() - con.upload_installer(*args, **kwargs) + return con.upload_installer(*args, **kwargs) # Dependency packages @@ -753,12 +753,12 @@ def get_secrets(*args, **kwargs): def get_secret(*args, **kwargs): con = get_server_api_connection() - return con.delete_secret(*args, **kwargs) + return con.get_secret(*args, **kwargs) def save_secret(*args, **kwargs): con = get_server_api_connection() - return con.delete_secret(*args, **kwargs) + return con.save_secret(*args, **kwargs) def delete_secret(*args, **kwargs): @@ -978,12 +978,14 @@ def delete_project(project_name): def get_thumbnail_by_id(project_name, thumbnail_id): con = get_server_api_connection() - con.get_thumbnail_by_id(project_name, thumbnail_id) + return con.get_thumbnail_by_id(project_name, thumbnail_id) def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None): con = get_server_api_connection() - con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id) + return con.get_thumbnail( + project_name, entity_type, entity_id, thumbnail_id + ) def get_folder_thumbnail(project_name, folder_id, thumbnail_id=None): diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index 2435fc8a17..cedb3ed2ac 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -144,6 +144,7 @@ def product_types_query(fields): query_queue.append((k, v, field)) return query + def project_product_types_query(fields): query = GraphQlQuery("ProjectProductTypes") project_query = query.add_field("project") @@ -175,6 +176,8 @@ def folders_graphql_query(fields): parent_folder_ids_var = query.add_variable("parentFolderIds", "[String!]") folder_paths_var = query.add_variable("folderPaths", "[String!]") folder_names_var = query.add_variable("folderNames", "[String!]") + folder_types_var = query.add_variable("folderTypes", "[String!]") + statuses_var = query.add_variable("folderStatuses", "[String!]") has_products_var = query.add_variable("folderHasProducts", "Boolean!") project_field = query.add_field("project") @@ -185,6 +188,8 @@ def folders_graphql_query(fields): folders_field.set_filter("parentIds", parent_folder_ids_var) folders_field.set_filter("names", folder_names_var) folders_field.set_filter("paths", folder_paths_var) + folders_field.set_filter("folderTypes", folder_types_var) + folders_field.set_filter("statuses", statuses_var) folders_field.set_filter("hasProducts", has_products_var) nested_fields = fields_to_dict(fields) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index 511a239a83..3bac59c192 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -75,6 +75,7 @@ from .utils import ( TransferProgress, create_dependency_package_basename, ThumbnailContent, + get_default_timeout, ) PatternType = type(re.compile("")) @@ -351,7 +352,6 @@ class ServerAPI(object): timeout (Optional[float]): Timeout for requests. max_retries (Optional[int]): Number of retries for requests. """ - _default_timeout = 10.0 _default_max_retries = 3 def __init__( @@ -500,20 +500,13 @@ class ServerAPI(object): def get_default_timeout(cls): """Default value for requests timeout. - First looks for environment variable SERVER_TIMEOUT_ENV_KEY which - can affect timeout value. If not available then use class - attribute '_default_timeout'. + Utils function 'get_default_timeout' is used by default. Returns: float: Timeout value in seconds. """ - try: - return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY)) - except (ValueError, TypeError): - pass - - return cls._default_timeout + return get_default_timeout() @classmethod def get_default_max_retries(cls): @@ -662,13 +655,10 @@ class ServerAPI(object): as default variant. Args: - variant (Literal['production', 'staging']): Settings variant name. + variant (str): Settings variant name. It is possible to use + 'production', 'staging' or name of dev bundle. """ - if variant not in ("production", "staging"): - raise ValueError(( - "Invalid variant name {}. Expected 'production' or 'staging'" - ).format(variant)) self._default_settings_variant = variant default_settings_variant = property( @@ -938,8 +928,8 @@ class ServerAPI(object): int(re_match.group("major")), int(re_match.group("minor")), int(re_match.group("patch")), - re_match.group("prerelease"), - re_match.group("buildmetadata") + re_match.group("prerelease") or "", + re_match.group("buildmetadata") or "", ) return self._server_version_tuple @@ -1140,31 +1130,41 @@ class ServerAPI(object): response = None new_response = None - for _ in range(max_retries): + for retry_idx in reversed(range(max_retries)): try: response = function(url, **kwargs) break except ConnectionRefusedError: + if retry_idx == 0: + self.log.warning( + "Connection error happened.", exc_info=True + ) + # Server may be restarting new_response = RestApiResponse( None, {"detail": "Unable to connect the server. Connection refused"} ) + except requests.exceptions.Timeout: # Connection timed out new_response = RestApiResponse( None, {"detail": "Connection timed out."} ) + except requests.exceptions.ConnectionError: - # Other connection error (ssl, etc) - does not make sense to - # try call server again + # Log warning only on last attempt + if retry_idx == 0: + self.log.warning( + "Connection error happened.", exc_info=True + ) + new_response = RestApiResponse( None, {"detail": "Unable to connect the server. Connection error"} ) - break time.sleep(0.1) @@ -1349,7 +1349,9 @@ class ServerAPI(object): status=None, description=None, summary=None, - payload=None + payload=None, + progress=None, + retries=None ): kwargs = { key: value @@ -1360,9 +1362,27 @@ class ServerAPI(object): ("description", description), ("summary", summary), ("payload", payload), + ("progress", progress), + ("retries", retries), ) if value is not None } + # 'progress' and 'retries' are available since 0.5.x server version + major, minor, _, _, _ = self.server_version_tuple + if (major, minor) < (0, 5): + args = [] + if progress is not None: + args.append("progress") + if retries is not None: + args.append("retries") + fields = ", ".join("'{}'".format(f) for f in args) + ending = "s" if len(args) > 1 else "" + raise ValueError(( + "Your server version '{}' does not support update" + " of {} field{} on event. The fields are supported since" + " server version '0.5'." + ).format(self.get_server_version(), fields, ending)) + response = self.patch( "events/{}".format(event_id), **kwargs @@ -1434,6 +1454,7 @@ class ServerAPI(object): description=None, sequential=None, events_filter=None, + max_retries=None, ): """Enroll job based on events. @@ -1475,8 +1496,12 @@ class ServerAPI(object): in target event. sequential (Optional[bool]): The source topic must be processed in sequence. - events_filter (Optional[ayon_server.sqlfilter.Filter]): A dict-like - with conditions to filter the source event. + events_filter (Optional[dict[str, Any]]): Filtering conditions + to filter the source event. For more technical specifications + look to server backed 'ayon_server.sqlfilter.Filter'. + TODO: Add example of filters. + max_retries (Optional[int]): How many times can be event retried. + Default value is based on server (3 at the time of this PR). Returns: Union[None, dict[str, Any]]: None if there is no event matching @@ -1487,6 +1512,7 @@ class ServerAPI(object): "sourceTopic": source_topic, "targetTopic": target_topic, "sender": sender, + "maxRetries": max_retries, } if sequential is not None: kwargs["sequential"] = sequential @@ -2236,6 +2262,34 @@ class ServerAPI(object): response.raise_for_status("Failed to create/update dependency") return response.data + def _get_dependency_package_route( + self, filename=None, platform_name=None + ): + major, minor, patch, _, _ = self.server_version_tuple + if (major, minor, patch) <= (0, 2, 0): + # Backwards compatibility for AYON server 0.2.0 and lower + self.log.warning(( + "Using deprecated dependency package route." + " Please update your AYON server to version 0.2.1 or higher." + " Backwards compatibility for this route will be removed" + " in future releases of ayon-python-api." + )) + if platform_name is None: + platform_name = platform.system().lower() + base = "dependencies" + if not filename: + return base + return "{}/{}/{}".format(base, filename, platform_name) + + if (major, minor) <= (0, 3): + endpoint = "desktop/dependency_packages" + else: + endpoint = "desktop/dependencyPackages" + + if filename: + return "{}/{}".format(endpoint, filename) + return endpoint + def get_dependency_packages(self): """Information about dependency packages on server. @@ -2263,33 +2317,11 @@ class ServerAPI(object): server. """ - endpoint = "desktop/dependencyPackages" - major, minor, _, _, _ = self.server_version_tuple - if major == 0 and minor <= 3: - endpoint = "desktop/dependency_packages" - + endpoint = self._get_dependency_package_route() result = self.get(endpoint) result.raise_for_status() return result.data - def _get_dependency_package_route( - self, filename=None, platform_name=None - ): - major, minor, patch, _, _ = self.server_version_tuple - if major == 0 and (minor > 2 or (minor == 2 and patch >= 1)): - base = "desktop/dependency_packages" - if not filename: - return base - return "{}/{}".format(base, filename) - - # Backwards compatibility for AYON server 0.2.0 and lower - if platform_name is None: - platform_name = platform.system().lower() - base = "dependencies" - if not filename: - return base - return "{}/{}/{}".format(base, filename, platform_name) - def create_dependency_package( self, filename, @@ -3515,7 +3547,9 @@ class ServerAPI(object): folder_ids=None, folder_paths=None, folder_names=None, + folder_types=None, parent_ids=None, + statuses=None, active=True, fields=None, own_attributes=False @@ -3536,8 +3570,12 @@ class ServerAPI(object): for filtering. folder_names (Optional[Iterable[str]]): Folder names used for filtering. + folder_types (Optional[Iterable[str]]): Folder types used + for filtering. parent_ids (Optional[Iterable[str]]): Ids of folder parents. Use 'None' if folder is direct child of project. + statuses (Optional[Iterable[str]]): Folder statuses used + for filtering. active (Optional[bool]): Filter active/inactive folders. Both are returned if is set to None. fields (Optional[Iterable[str]]): Fields to be queried for @@ -3574,6 +3612,18 @@ class ServerAPI(object): return filters["folderNames"] = list(folder_names) + if folder_types is not None: + folder_types = set(folder_types) + if not folder_types: + return + filters["folderTypes"] = list(folder_types) + + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["folderStatuses"] = list(statuses) + if parent_ids is not None: parent_ids = set(parent_ids) if not parent_ids: @@ -4312,9 +4362,6 @@ class ServerAPI(object): fields.remove("attrib") fields |= self.get_attributes_fields_for_type("version") - if active is not None: - fields.add("active") - # Make sure fields have minimum required fields fields |= {"id", "version"} @@ -4323,6 +4370,9 @@ class ServerAPI(object): use_rest = True fields = {"id"} + if active is not None: + fields.add("active") + if own_attributes: fields.add("ownAttrib") @@ -5845,19 +5895,22 @@ class ServerAPI(object): """Helper method to get links from server for entity types. Example output: - [ - { - "id": "59a212c0d2e211eda0e20242ac120002", - "linkType": "reference", - "description": "reference link between folders", - "projectName": "my_project", - "author": "frantadmin", - "entityId": "b1df109676db11ed8e8c6c9466b19aa8", - "entityType": "folder", - "direction": "out" - }, + { + "59a212c0d2e211eda0e20242ac120001": [ + { + "id": "59a212c0d2e211eda0e20242ac120002", + "linkType": "reference", + "description": "reference link between folders", + "projectName": "my_project", + "author": "frantadmin", + "entityId": "b1df109676db11ed8e8c6c9466b19aa8", + "entityType": "folder", + "direction": "out" + }, + ... + ], ... - ] + } Args: project_name (str): Project where links are. diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 314d13faec..502d24f713 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -1,3 +1,4 @@ +import os import re import datetime import uuid @@ -15,6 +16,7 @@ except ImportError: import requests import unidecode +from .constants import SERVER_TIMEOUT_ENV_KEY from .exceptions import UrlError REMOVED_VALUE = object() @@ -27,6 +29,23 @@ RepresentationParents = collections.namedtuple( ) +def get_default_timeout(): + """Default value for requests timeout. + + First looks for environment variable SERVER_TIMEOUT_ENV_KEY which + can affect timeout value. If not available then use 10.0 s. + + Returns: + float: Timeout value in seconds. + """ + + try: + return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY)) + except (ValueError, TypeError): + pass + return 10.0 + + class ThumbnailContent: """Wrapper for thumbnail content. @@ -231,30 +250,36 @@ def _try_parse_url(url): return None -def _try_connect_to_server(url): +def _try_connect_to_server(url, timeout=None): + if timeout is None: + timeout = get_default_timeout() try: # TODO add validation if the url lead to Ayon server - # - thiw won't validate if the url lead to 'google.com' - requests.get(url) + # - this won't validate if the url lead to 'google.com' + requests.get(url, timeout=timeout) except BaseException: return False return True -def login_to_server(url, username, password): +def login_to_server(url, username, password, timeout=None): """Use login to the server to receive token. Args: url (str): Server url. username (str): User's username. password (str): User's password. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. Returns: Union[str, None]: User's token if login was successfull. Otherwise 'None'. """ + if timeout is None: + timeout = get_default_timeout() headers = {"Content-Type": "application/json"} response = requests.post( "{}/api/auth/login".format(url), @@ -262,7 +287,8 @@ def login_to_server(url, username, password): json={ "name": username, "password": password - } + }, + timeout=timeout, ) token = None # 200 - success @@ -273,47 +299,67 @@ def login_to_server(url, username, password): return token -def logout_from_server(url, token): +def logout_from_server(url, token, timeout=None): """Logout from server and throw token away. Args: url (str): Url from which should be logged out. token (str): Token which should be used to log out. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. """ + if timeout is None: + timeout = get_default_timeout() headers = { "Content-Type": "application/json", "Authorization": "Bearer {}".format(token) } requests.post( url + "/api/auth/logout", - headers=headers + headers=headers, + timeout=timeout, ) -def is_token_valid(url, token): +def is_token_valid(url, token, timeout=None): """Check if token is valid. + Token can be a user token or service api key. + Args: url (str): Server url. token (str): User's token. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. Returns: bool: True if token is valid. """ - headers = { + if timeout is None: + timeout = get_default_timeout() + + base_headers = { "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token) } - response = requests.get( - "{}/api/users/me".format(url), - headers=headers - ) - return response.status_code == 200 + for header_value in ( + {"Authorization": "Bearer {}".format(token)}, + {"X-Api-Key": token}, + ): + headers = base_headers.copy() + headers.update(header_value) + response = requests.get( + "{}/api/users/me".format(url), + headers=headers, + timeout=timeout, + ) + if response.status_code == 200: + return True + return False -def validate_url(url): +def validate_url(url, timeout=None): """Validate url if is valid and server is available. Validation checks if can be parsed as url and contains scheme. @@ -334,6 +380,7 @@ def validate_url(url): Args: url (str): Server url. + timeout (Optional[int]): Timeout in seconds for connection to server. Returns: Url which was used to connect to server. @@ -369,10 +416,10 @@ def validate_url(url): # - this will trigger UrlError if both will crash if not parsed_url.scheme: new_url = "https://" + modified_url - if _try_connect_to_server(new_url): + if _try_connect_to_server(new_url, timeout=timeout): return new_url - if _try_connect_to_server(modified_url): + if _try_connect_to_server(modified_url, timeout=timeout): return modified_url hints = [] diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index f3826a6407..ac4f32997f 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.4.1" +__version__ = "0.5.1" From 211d64c3dea458b18ba268a66fa2e292dcf0ed7d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 18 Oct 2023 11:39:31 +0300 Subject: [PATCH 0638/1224] align houdini shelves manager in OP and Ayon --- .../schemas/schema_houdini_scriptshelf.json | 6 ++++- .../houdini/server/settings/shelves.py | 25 +++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index bab9b604b4..35d768843d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -40,6 +40,10 @@ "object_type": { "type": "dict", "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, { "type": "text", "key": "label", @@ -68,4 +72,4 @@ } ] } -} \ No newline at end of file +} diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index c8bda515f9..ac7922e058 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -1,23 +1,16 @@ from pydantic import Field from ayon_server.settings import ( BaseSettingsModel, - MultiplatformPathModel, - MultiplatformPathListModel, + MultiplatformPathModel ) class ShelfToolsModel(BaseSettingsModel): - name: str = Field(title="Name") - help: str = Field(title="Help text") - # TODO: The following settings are not compatible with OP - script: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Script Path " - ) - icon: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Icon Path " - ) + """Name and Script Path are mandatory.""" + label: str = Field(title="Name") + script: str = Field(title="Script Path") + icon: str = Field( "", title="Icon Path") + help: str = Field("", title="Help text") class ShelfDefinitionModel(BaseSettingsModel): @@ -31,10 +24,10 @@ class ShelfDefinitionModel(BaseSettingsModel): class ShelvesModel(BaseSettingsModel): _layout = "expanded" - shelf_set_name: str = Field(title="Shelfs set name") + shelf_set_name: str = Field("", title="Shelfs set name") - shelf_set_source_path: MultiplatformPathListModel = Field( - default_factory=MultiplatformPathListModel, + shelf_set_source_path: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, title="Shelf Set Path (optional)" ) From a704fd44e8731cbe0ee17b042a5cc4808f87b2d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 10:45:52 +0200 Subject: [PATCH 0639/1224] ignore some predefined names to import --- openpype/modules/base.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 6f3e4566f3..080be251f3 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -434,8 +434,18 @@ def _load_ayon_addons(openpype_modules, modules_key, log): sys.path.insert(0, addon_dir) imported_modules = [] for name in os.listdir(addon_dir): + # Ignore of files is implemented to be able to run code from code + # where usually is more files than just the addon + # Ignore start and setup scripts + if name in ("setup.py", "start.py"): + continue + path = os.path.join(addon_dir, name) basename, ext = os.path.splitext(name) + # Ignore folders/files with dot in name + # - dot names cannot be imported in Python + if "." in basename: + continue is_dir = os.path.isdir(path) is_py_file = ext.lower() == ".py" if not is_py_file and not is_dir: From 6fb59e3085f7d621dcbde1d9b8ed8ed82081e51b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 18 Oct 2023 12:36:43 +0300 Subject: [PATCH 0640/1224] resolve hound --- server_addon/houdini/server/settings/shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index ac7922e058..8d0512bdeb 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -9,7 +9,7 @@ class ShelfToolsModel(BaseSettingsModel): """Name and Script Path are mandatory.""" label: str = Field(title="Name") script: str = Field(title="Script Path") - icon: str = Field( "", title="Icon Path") + icon: str = Field("", title="Icon Path") help: str = Field("", title="Help text") From b20f59e87ed1c98f678b136ee011918bb54e9b7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 11:53:50 +0200 Subject: [PATCH 0641/1224] formatting fix --- openpype/settings/ayon_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index eb64480dc3..7d4675c0f3 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1439,8 +1439,8 @@ class _AyonSettingsCache: and bundle.get("activeUser") == username ): return bundle["name"] - # Return fake variant - distribution logic will tell user that he does not - # have set any dev bundle + # Return fake variant - distribution logic will tell user that he + # does not have set any dev bundle return "dev" @classmethod From 489a502550fdf610b0afeb91a33266b32793a344 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:15:18 +0200 Subject: [PATCH 0642/1224] reverting backslash removal --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index b061271f5a..ab0d0f4971 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2302,7 +2302,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. if env_path in path: # with regsub we make sure path format of slashes is correct resub_expr = ( - "[regsub -all {{\\}} [getenv {}] \"/\"]").format(env_var) + "[regsub -all {{\\\\}} [getenv {}] \"/\"]").format(env_var) new_path = path.replace( env_path, resub_expr From 65cfa7751c2b9805ff7e60bb24e6dae7c97e81be Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 12:22:10 +0200 Subject: [PATCH 0643/1224] added disk mapping settings to core addon --- server_addon/core/server/settings/main.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py index ca8f7e63ed..433d0ef2f0 100644 --- a/server_addon/core/server/settings/main.py +++ b/server_addon/core/server/settings/main.py @@ -12,6 +12,27 @@ from .publish_plugins import PublishPuginsModel, DEFAULT_PUBLISH_VALUES from .tools import GlobalToolsModel, DEFAULT_TOOLS_VALUES +class DiskMappingItemModel(BaseSettingsModel): + _layout = "expanded" + source: str = Field("", title="Source") + destination: str = Field("", title="Destination") + + +class DiskMappingModel(BaseSettingsModel): + windows: list[DiskMappingItemModel] = Field( + title="Windows", + default_factory=list, + ) + linux: list[DiskMappingItemModel] = Field( + title="Linux", + default_factory=list, + ) + darwin: list[DiskMappingItemModel] = Field( + title="MacOS", + default_factory=list, + ) + + class ImageIOFileRuleModel(BaseSettingsModel): name: str = Field("", title="Rule name") pattern: str = Field("", title="Regex pattern") @@ -97,6 +118,10 @@ class CoreSettings(BaseSettingsModel): widget="textarea", scope=["studio"], ) + disk_mapping: DiskMappingModel = Field( + default_factory=DiskMappingModel, + title="Disk mapping", + ) tools: GlobalToolsModel = Field( default_factory=GlobalToolsModel, title="Tools" From a810dfe78a97eb1467d3745ee786f2743f6faac2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:30:57 +0200 Subject: [PATCH 0644/1224] fixing assert message --- openpype/hosts/resolve/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 6f84d921e0..798c40a864 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -308,9 +308,9 @@ def create_timeline_item( assert output_timeline_item, AssertionError(( "Clip name '{}' was't created on the timeline: '{}' \n\n" - "Please check if the clip is in the media pool or if the timeline \n" - "is having activated correct track name, or if it is not already \n" - "having any clip add place on the timeline in: '{}' out: '{}'. \n\n" + "Please check if correct track position is activated, \n" + "or if a clip is not already at the timeline in \n" + "position: '{}' out: '{}'. \n\n" "Clip data: {}" ).format( clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data From a11467883b5d1cec302f43361e7d939ff502ac07 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:34:12 +0200 Subject: [PATCH 0645/1224] getting fps from timeline instead of project --- openpype/hosts/resolve/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 798c40a864..aef9caca78 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -278,7 +278,7 @@ def create_timeline_item( # timing variables if all([timeline_in, source_start, source_end]): - fps = project.GetSetting("timelineFrameRate") + fps = timeline.GetSetting("timelineFrameRate") duration = source_end - source_start timecode_in = frames_to_timecode(timeline_in, fps) timecode_out = frames_to_timecode(timeline_in + duration, fps) From c61a601c78669d70c472c67016eeb77531f42bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Wed, 18 Oct 2023 12:35:21 +0200 Subject: [PATCH 0646/1224] :bug: fix key in applicaiton json (#5787) `maya` was wrongly used instead of `mayapy`, breaking AYON defaults --- server_addon/applications/server/applications.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index 171bd709a6..db7f86e357 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -69,7 +69,7 @@ } ] }, - "maya": { + "mayapy": { "enabled": true, "label": "Maya", "icon": "{}/app_icons/maya.png", From fbc24f565415cc6cbb88547c9b333bf41a52a3e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 12:48:50 +0200 Subject: [PATCH 0647/1224] make sure handles on timeline are included only if available or demanded --- openpype/hosts/resolve/api/plugin.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index c88ed762ab..9a09685bee 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -410,6 +410,18 @@ class ClipLoader: if handle_end is None: handle_end = int(self.data["assetData"]["handleEnd"]) + # check frame duration from versionData or assetData + frame_start = self.data["versionData"].get("frameStart") + if frame_start is None: + frame_start = self.data["assetData"]["frameStart"] + + # check frame duration from versionData or assetData + frame_end = self.data["versionData"].get("frameEnd") + if frame_end is None: + frame_end = self.data["assetData"]["frameEnd"] + + db_frame_duration = int(frame_end) - int(frame_start) + 1 + # get timeline in timeline_start = self.active_timeline.GetStartFrame() if self.sequential_load: @@ -423,10 +435,17 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) - # include handles + # check if source duration is shorter than db frame duration + source_with_handles = True + source_duration = source_out - source_in + 1 + if source_duration < db_frame_duration: + source_with_handles = False + + # only exclude handles if source has no handles or + # if user wants to load without handles if ( not self.with_handles - or _clip_property("Type") == "Video" + or not source_with_handles ): source_in += handle_start source_out -= handle_end From 105720ff0d3f999a735cfaeb0783997c13131b4e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 12:55:17 +0200 Subject: [PATCH 0648/1224] add dev icons --- openpype/resources/__init__.py | 7 ++++++- openpype/resources/icons/AYON_icon_dev.png | Bin 0 -> 17344 bytes openpype/resources/icons/AYON_splash_dev.png | Bin 0 -> 21796 bytes 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 openpype/resources/icons/AYON_icon_dev.png create mode 100644 openpype/resources/icons/AYON_splash_dev.png diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index b8671f517a..b33d1bf023 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -55,6 +55,9 @@ def get_openpype_staging_icon_filepath(): def get_openpype_icon_filepath(staging=None): + if AYON_SERVER_ENABLED and os.getenv("AYON_USE_DEV") == "1": + return get_resource("icons", "AYON_icon_dev.png") + if staging is None: staging = is_running_staging() @@ -68,7 +71,9 @@ def get_openpype_splash_filepath(staging=None): staging = is_running_staging() if AYON_SERVER_ENABLED: - if staging: + if os.getenv("AYON_USE_DEV") == "1": + splash_file_name = "AYON_splash_dev.png" + elif staging: splash_file_name = "AYON_splash_staging.png" else: splash_file_name = "AYON_splash.png" diff --git a/openpype/resources/icons/AYON_icon_dev.png b/openpype/resources/icons/AYON_icon_dev.png new file mode 100644 index 0000000000000000000000000000000000000000..e99a64d475d1e5841e4994bcc7705aea8202b0fd GIT binary patch literal 17344 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 V7AFPXfnNdvX=~`BO0HQw|9|wzLIwZ; literal 0 HcmV?d00001 diff --git a/openpype/resources/icons/AYON_splash_dev.png b/openpype/resources/icons/AYON_splash_dev.png new file mode 100644 index 0000000000000000000000000000000000000000..3b366e755d841bde1b82b6e5c40457adff4d39dc GIT binary patch literal 21796 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 y7sY-0mXdy4=HZ|fKIJ9}0RQi Date: Wed, 18 Oct 2023 13:05:23 +0200 Subject: [PATCH 0649/1224] improving code readability --- openpype/hosts/resolve/api/plugin.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 9a09685bee..63da14b1c2 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -306,14 +306,18 @@ class ClipLoader: self.active_project = lib.get_current_project() # try to get value from options or evaluate key value for `handles` - self.with_handles = options.get("handles") or bool( - options.get("handles") is True) + self.with_handles = options.get("handles") is True + # try to get value from options or evaluate key value for `load_to` - self.new_timeline = options.get("newTimeline") or bool( - "New timeline" in options.get("load_to", "")) + self.new_timeline = ( + options.get("newTimeline") or + options.get("load_to") == "New timeline" + ) # try to get value from options or evaluate key value for `load_how` - self.sequential_load = options.get("sequentially") or bool( - "Sequentially in order" in options.get("load_how", "")) + self.sequential_load = ( + options.get("sequentially") or + options.get("load_how") == "Sequentially in order" + ) assert self._populate_data(), str( "Cannot Load selected data, look into database " From c49289bb8a30e22b020e2b35a7c60b73dddfa69e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 19:20:31 +0800 Subject: [PATCH 0650/1224] add use selection back to the creator option & cleanup the viewport setting code --- openpype/hosts/max/api/lib.py | 96 +++++++++---------- .../hosts/max/plugins/create/create_review.py | 3 +- .../max/plugins/publish/collect_review.py | 57 +++++++---- .../publish/extract_review_animation.py | 18 ++-- 4 files changed, 98 insertions(+), 76 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 736b0fb544..48656842de 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -323,7 +323,7 @@ def is_headless(): @contextlib.contextmanager -def viewport_setup_updated(camera): +def viewport_camera(camera): """Function to set viewport camera during context ***For 3dsMax 2024+ Args: @@ -346,64 +346,55 @@ def viewport_setup_updated(camera): @contextlib.contextmanager -def viewport_setup(instance, viewport_setting, camera): - """Function to set camera and other viewport options - during context - ****For Max Version < 2024 - - Args: - instance (str): instance - viewport_setting (str): active viewport setting - camera (str): viewport camera - - """ - original = rt.viewport.getCamera() - has_vp_btn = rt.ViewportButtonMgr.EnableButtons - has_autoplay = rt.preferences.playPreviewWhenDone - if not original: +def viewport_preference_setting(camera, + general_viewport, + nitrous_viewport, + vp_button_mgr, + preview_preferences): + original_camera = rt.viewport.getCamera() + if not original_camera: # if there is no original camera # use the current camera as original - original = rt.getNodeByName(camera) + original_camera = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) - - current_visualStyle = viewport_setting.VisualStyleMode - current_visualPreset = viewport_setting.ViewportPreset - current_useTexture = viewport_setting.UseTextureEnabled orig_vp_grid = rt.viewport.getGridVisibility(1) orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() - visualStyle = instance.data.get("visualStyleMode") - viewportPreset = instance.data.get("viewportPreset") - useTexture = instance.data.get("vpTexture") - has_grid_viewport = instance.data.get("dspGrid") - bkg_color_viewport = instance.data.get("dspBkg") - + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + vp_button_mgr_original = { + key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr + } + nitrous_viewport_original = { + key: getattr(viewport_setting, key) for key in nitrous_viewport + } + preview_preferences_original = { + key: getattr(rt.preferences, key) for key in preview_preferences + } try: rt.viewport.setCamera(review_camera) - rt.viewport.setGridVisibility(1, has_grid_viewport) - rt.preferences.playPreviewWhenDone = False - rt.ViewportButtonMgr.EnableButtons = False - rt.viewport.EnableSolidBackgroundColorMode( - bkg_color_viewport) - if visualStyle != current_visualStyle: - viewport_setting.VisualStyleMode = rt.Name( - visualStyle) - elif viewportPreset != current_visualPreset: - viewport_setting.ViewportPreset = rt.Name( - viewportPreset) - elif useTexture != current_useTexture: - viewport_setting.UseTextureEnabled = useTexture + rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) + rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) + for key, value in vp_button_mgr.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_viewport.items(): + if nitrous_viewport[key] != nitrous_viewport_original[key]: + setattr(viewport_setting, key, value) + for key, value in preview_preferences.items(): + setattr(rt.preferences, key, value) yield + finally: - rt.viewport.setCamera(original) + rt.viewport.setCamera(review_camera) rt.viewport.setGridVisibility(1, orig_vp_grid) rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) - viewport_setting.VisualStyleMode = current_visualStyle - viewport_setting.ViewportPreset = current_visualPreset - viewport_setting.UseTextureEnabled = current_useTexture - rt.ViewportButtonMgr.EnableButtons = has_vp_btn - rt.preferences.playPreviewWhenDone = has_autoplay - + for key, value in vp_button_mgr_original.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_viewport_original.items(): + setattr(viewport_setting, key, value) + for key, value in preview_preferences_original.items(): + setattr(rt.preferences, key, value) + rt.completeRedraw() def set_timeline(frameStart, frameEnd): @@ -630,7 +621,8 @@ def publish_review_animation(instance, filepath, def publish_preview_sequences(staging_dir, filename, - startFrame, endFrame, ext): + startFrame, endFrame, + percentSize, ext): """publish preview animation by creating bitmaps ***For 3dsMax Version <2024 @@ -639,13 +631,15 @@ def publish_preview_sequences(staging_dir, filename, filename (str): filename startFrame (int): start frame endFrame (int): end frame + percentSize (int): percentage of the resolution ext (str): image extension """ # get the screenshot rt.forceCompleteRedraw() rt.enableSceneRedraw() - res_width = rt.renderWidth - res_height = rt.renderHeight + resolution_percentage = float(percentSize) / 100 + res_width = rt.renderWidth * resolution_percentage + res_height = rt.renderHeight * resolution_percentage viewportRatio = float(res_width / res_height) @@ -684,4 +678,4 @@ def publish_preview_sequences(staging_dir, filename, if rt.keyboard.escPressed: rt.exit() # clean up the cache - rt.gc() + rt.gc(delayed=True) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index ea56123c79..977c018f5c 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -75,4 +75,5 @@ class CreateReview(plugin.MaxCreator): def get_pre_create_attr_defs(self): # Use same attributes as for instance attributes - return self.get_instance_attr_defs() + attrs = super().get_pre_create_attr_defs() + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 9ab1d6f3a8..6e9a6c870e 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -27,28 +27,15 @@ class CollectReview(pyblish.api.InstancePlugin, focal_length = node.fov creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) - data = { + + general_preview_data = { "review_camera": camera_name, "imageFormat": creator_attrs["imageFormat"], "keepImages": creator_attrs["keepImages"], "percentSize": creator_attrs["percentSize"], - "visualStyleMode": creator_attrs["visualStyleMode"], - "viewportPreset": creator_attrs["viewportPreset"], - "vpTexture": creator_attrs["vpTexture"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], - "dspGeometry": attr_values.get("dspGeometry"), - "dspShapes": attr_values.get("dspShapes"), - "dspLights": attr_values.get("dspLights"), - "dspCameras": attr_values.get("dspCameras"), - "dspHelpers": attr_values.get("dspHelpers"), - "dspParticles": attr_values.get("dspParticles"), - "dspBones": attr_values.get("dspBones"), - "dspBkg": attr_values.get("dspBkg"), - "dspGrid": attr_values.get("dspGrid"), - "dspSafeFrame": attr_values.get("dspSafeFrame"), - "dspFrameNums": attr_values.get("dspFrameNums") } if int(get_max_version()) >= 2024: @@ -61,14 +48,50 @@ class CollectReview(pyblish.api.InstancePlugin, instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform + preview_data = { + "visualStyleMode": creator_attrs["visualStyleMode"], + "viewportPreset": creator_attrs["viewportPreset"], + "vpTexture": creator_attrs["vpTexture"], + "dspGeometry": attr_values.get("dspGeometry"), + "dspShapes": attr_values.get("dspShapes"), + "dspLights": attr_values.get("dspLights"), + "dspCameras": attr_values.get("dspCameras"), + "dspHelpers": attr_values.get("dspHelpers"), + "dspParticles": attr_values.get("dspParticles"), + "dspBones": attr_values.get("dspBones"), + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid"), + "dspSafeFrame": attr_values.get("dspSafeFrame"), + "dspFrameNums": attr_values.get("dspFrameNums") + } + else: + preview_data = {} + general_viewport = { + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid") + } + nitrous_viewport = { + "VisualStyleMode": creator_attrs["visualStyleMode"], + "ViewportPreset": creator_attrs["viewportPreset"], + "UseTextureEnabled": creator_attrs["vpTexture"] + } + preview_data["general_viewport"] = general_viewport + preview_data["nitrous_viewport"] = nitrous_viewport + preview_data["vp_button_manager"] = { + "EnableButtons" : False + } + preview_data["preferences"] = { + "playPreviewWhenDone": False + } + # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') burnin_members = instance.data.setdefault("burninDataMembers", {}) burnin_members["focalLength"] = focal_length - instance.data.update(data) - self.log.debug(f"data:{data}") + instance.data.update(general_preview_data) + instance.data.update(preview_data) @classmethod def get_attribute_defs(cls): diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index a77f6213fa..2fbcb157a3 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -3,8 +3,8 @@ import pyblish.api from pymxs import runtime as rt from openpype.pipeline import publish from openpype.hosts.max.api.lib import ( - viewport_setup_updated, - viewport_setup, + viewport_camera, + viewport_preference_setting, get_max_version, publish_review_animation, publish_preview_sequences @@ -39,13 +39,17 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] if int(get_max_version()) < 2024: - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - with viewport_setup(instance, viewport_setting, review_camera): + with viewport_preference_setting(review_camera, + instance.data["general_viewport"], + instance.data["nitrous_viewport"], + instance.data["vp_button_manager"], + instance.data["preferences"]): + percentSize = instance.data.get("percentSize") publish_preview_sequences( - staging_dir, instance.name, start, end, ext) + staging_dir, instance.name, + start, end, percentSize, ext) else: - with viewport_setup_updated(review_camera): + with viewport_camera(review_camera): preview_arg = publish_review_animation( instance, filepath, start, end, fps) rt.execute(preview_arg) From d0b397f130b997660b843bd6d7fdd79fa2295b7a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 19:23:30 +0800 Subject: [PATCH 0651/1224] hound --- openpype/hosts/max/plugins/publish/collect_review.py | 6 +++--- .../hosts/max/plugins/publish/extract_review_animation.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 6e9a6c870e..21f63a8c73 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -66,7 +66,7 @@ class CollectReview(pyblish.api.InstancePlugin, } else: preview_data = {} - general_viewport = { + general_viewport = { "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid") } @@ -77,8 +77,8 @@ class CollectReview(pyblish.api.InstancePlugin, } preview_data["general_viewport"] = general_viewport preview_data["nitrous_viewport"] = nitrous_viewport - preview_data["vp_button_manager"] = { - "EnableButtons" : False + preview_data["vp_btn_mgr"] = { + "EnableButtons": False } preview_data["preferences"] = { "playPreviewWhenDone": False diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 2fbcb157a3..ccd641f619 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -42,7 +42,7 @@ class ExtractReviewAnimation(publish.Extractor): with viewport_preference_setting(review_camera, instance.data["general_viewport"], instance.data["nitrous_viewport"], - instance.data["vp_button_manager"], + instance.data["vp_btn_mgr"], instance.data["preferences"]): percentSize = instance.data.get("percentSize") publish_preview_sequences( From 6f2718ee4d64532380ee56774f0c0339a9fa3465 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 19:29:43 +0800 Subject: [PATCH 0652/1224] add missing docstrings --- openpype/hosts/max/api/lib.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 48656842de..8103eaecc5 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -351,6 +351,16 @@ def viewport_preference_setting(camera, nitrous_viewport, vp_button_mgr, preview_preferences): + """Function to set viewport setting during context + + Args: + camera (str): Viewport camera for review render + general_viewport (dict): General viewport setting + nitrous_viewport (dict): Nitrous setting for + preview animation + vp_button_mgr (dict): Viewport button manager Setting + preview_preferences (dict): Preview Preferences Setting + """ original_camera = rt.viewport.getCamera() if not original_camera: # if there is no original camera From a993a2999ed8d32dbcfa53c055e4bd73971fe2f7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 19:30:12 +0800 Subject: [PATCH 0653/1224] add missing docstrings --- 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 8103eaecc5..8c0bacf792 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -352,7 +352,7 @@ def viewport_preference_setting(camera, vp_button_mgr, preview_preferences): """Function to set viewport setting during context - + ***For Max Version < 2024 Args: camera (str): Viewport camera for review render general_viewport (dict): General viewport setting From aa74d48836c990a66413e821bd09628e5480a00f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 14:00:57 +0200 Subject: [PATCH 0654/1224] clip duration from Frames clip attributes --- openpype/hosts/resolve/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 63da14b1c2..5c4a92df89 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -438,10 +438,10 @@ class ClipLoader: source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) + source_duration = int(_clip_property("Frames")) # check if source duration is shorter than db frame duration source_with_handles = True - source_duration = source_out - source_in + 1 if source_duration < db_frame_duration: source_with_handles = False From 8653accdbaa15830b7cf02cde75d3dc13d1b96ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 18 Oct 2023 14:04:28 +0200 Subject: [PATCH 0655/1224] Update openpype/settings/ayon_settings.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/settings/ayon_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index f8ab067fca..f1e08a9fd1 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -843,7 +843,7 @@ def _convert_nuke_project_settings(ayon_settings, output): # nodes ayon_imageio_nodes = ayon_imageio["nodes"] - if ayon_imageio_nodes.get("required_nodes"): + if "required_nodes" in ayon_imageio_nodes: ayon_imageio_nodes["requiredNodes"] = ( ayon_imageio_nodes.pop("required_nodes")) if ayon_imageio_nodes.get("override_nodes"): From 512986ea677c8c8ce910b69efed9c17291fcae48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 18 Oct 2023 14:04:47 +0200 Subject: [PATCH 0656/1224] Update openpype/settings/ayon_settings.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/settings/ayon_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index f1e08a9fd1..1941f85655 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -846,7 +846,7 @@ def _convert_nuke_project_settings(ayon_settings, output): if "required_nodes" in ayon_imageio_nodes: ayon_imageio_nodes["requiredNodes"] = ( ayon_imageio_nodes.pop("required_nodes")) - if ayon_imageio_nodes.get("override_nodes"): + if "override_nodes" in ayon_imageio_nodes: ayon_imageio_nodes["overrideNodes"] = ( ayon_imageio_nodes.pop("override_nodes")) From 84e3c8c8ad8e26ae56f7b1325c0441d0c35f4e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 18 Oct 2023 14:05:22 +0200 Subject: [PATCH 0657/1224] Update openpype/settings/ayon_settings.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/settings/ayon_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 1941f85655..43c0a1483c 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -851,8 +851,8 @@ def _convert_nuke_project_settings(ayon_settings, output): ayon_imageio_nodes.pop("override_nodes")) for item in ayon_imageio_nodes["requiredNodes"]: - if item.get("nuke_node_class"): - item["nukeNodeClass"] = item["nuke_node_class"] + if "nuke_node_class" in item: + item["nukeNodeClass"] = item.pop("nuke_node_class") item["knobs"] = _convert_nuke_knobs(item["knobs"]) for item in ayon_imageio_nodes["overrideNodes"]: From 8acd3cc1277dfbc7f2f7df4480d9ad30ab3429e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 18 Oct 2023 14:05:41 +0200 Subject: [PATCH 0658/1224] Update openpype/settings/ayon_settings.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/settings/ayon_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 43c0a1483c..4d7b5c46af 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -856,8 +856,8 @@ def _convert_nuke_project_settings(ayon_settings, output): item["knobs"] = _convert_nuke_knobs(item["knobs"]) for item in ayon_imageio_nodes["overrideNodes"]: - if item.get("nuke_node_class"): - item["nukeNodeClass"] = item["nuke_node_class"] + if "nuke_node_class" in item: + item["nukeNodeClass"] = item.pop("nuke_node_class") item["knobs"] = _convert_nuke_knobs(item["knobs"]) output["nuke"] = ayon_nuke From b7e30d8fa3504efa7090d27fedf2a86eee15e534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 18 Oct 2023 14:08:44 +0200 Subject: [PATCH 0659/1224] Update openpype/settings/ayon_settings.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/settings/ayon_settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 4d7b5c46af..88af517932 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -836,10 +836,8 @@ def _convert_nuke_project_settings(ayon_settings, output): imageio_workfile[dst] = imageio_workfile.pop(src) # regex inputs - regex_inputs = ayon_imageio.get("regex_inputs") - if regex_inputs: - ayon_imageio.pop("regex_inputs") - ayon_imageio["regexInputs"] = regex_inputs + if "regex_inputs" in ayon_imageio: + ayon_imageio["regexInputs"] = ayon_imageio.pop("regex_inputs") # nodes ayon_imageio_nodes = ayon_imageio["nodes"] From 4771377cd5b72e0be91ddfcebf0114990772bcc7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 14:08:57 +0200 Subject: [PATCH 0660/1224] use white background icon --- openpype/resources/icons/AYON_icon_dev.png | Bin 17344 -> 15928 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/openpype/resources/icons/AYON_icon_dev.png b/openpype/resources/icons/AYON_icon_dev.png index e99a64d475d1e5841e4994bcc7705aea8202b0fd..3e867ef372db7e861663e672e1dd93e72ee8c7e1 100644 GIT binary patch literal 15928 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 literal 17344 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 V7AFPXfnNdvX=~`BO0HQw|9|wzLIwZ; From 620269c2fc56da2b629d6b9d65ff70234c7820ec Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 14:21:28 +0200 Subject: [PATCH 0661/1224] hound --- openpype/hosts/nuke/plugins/load/load_ociolook.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 29503ef4de..a22b0ae1af 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -39,7 +39,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): # json file variables schema_version = 1 - def load(self, context, name, namespace, data): """ Loading function to get the soft effects to particular read node @@ -66,10 +65,10 @@ class LoadOcioLookNodes(load.LoaderPlugin): group_node = self._create_group_node( object_name, filepath, json_f["data"]) - self._node_version_color(context["version"], group_node) - self.log.info("Loaded lut setup: `{}`".format(group_node["name"].value())) + self.log.info( + "Loaded lut setup: `{}`".format(group_node["name"].value())) return containerise( node=group_node, @@ -244,7 +243,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): self.log.info("Updated lut setup: `{}`".format( group_node["name"].value())) - def _load_json_data(self, filepath): # getting data from json file with unicode conversion with open(filepath, "r") as _file: @@ -304,6 +302,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): color_value = self.old_node_color node["tile_color"].setValue(int(color_value, 16)) + def _colorspace_name_by_type(colorspace_data): """ Returns colorspace name by type From 2445f6a1e4c6844bbc51c02093af8aac1360ba67 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 15:19:46 +0200 Subject: [PATCH 0662/1224] fixing lost commit content - loading updating fixed and container data are imprinted with updated representation id - not failing due `::` split on NoneType object - workfile colorspace is separated key from ocio look items - validator treating workfile colorspace key separately --- .../hosts/nuke/plugins/load/load_ociolook.py | 13 +++-- .../publish/collect_colorspace_look.py | 21 +++++--- .../publish/extract_colorspace_look.py | 4 +- .../publish/validate_colorspace_look.py | 52 +++++++++++++------ openpype/pipeline/colorspace.py | 3 ++ 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index a22b0ae1af..3413b85749 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -15,7 +15,8 @@ from openpype.pipeline import ( ) from openpype.hosts.nuke.api import ( containerise, - viewer_update_and_undo_stop + viewer_update_and_undo_stop, + update_container, ) @@ -122,9 +123,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): for node in group_node.nodes(): if node.Class() not in ["Input", "Output"]: nuke.delete(node) - if node.Class() == "Input": + elif node.Class() == "Input": input_node = node - if node.Class() == "Output": + elif node.Class() == "Output": output_node = node else: group_node = nuke.createNode( @@ -178,13 +179,12 @@ class LoadOcioLookNodes(load.LoaderPlugin): ( file for file in all_files if file.endswith(extension) - and item_name in file ), None ) if not item_lut_file: raise ValueError( - "File with extension {} not found in directory".format( + "File with extension '{}' not found in directory".format( extension)) item_lut_path = os.path.join( @@ -243,6 +243,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): self.log.info("Updated lut setup: `{}`".format( group_node["name"].value())) + return update_container( + group_node, {"representation": str(representation["_id"])}) + def _load_json_data(self, filepath): # getting data from json file with unicode conversion with open(filepath, "r") as _file: diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py index c7a886a619..f259b120e9 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -1,3 +1,4 @@ +from math import e import os from pprint import pformat import pyblish.api @@ -42,9 +43,18 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, "input_colorspace", "output_colorspace" ]: - color_data = colorspace.convert_colorspace_enumerator_item( - creator_attrs[colorspace_key], config_items) - converted_color_data[colorspace_key] = color_data + if creator_attrs[colorspace_key]: + color_data = colorspace.convert_colorspace_enumerator_item( + creator_attrs[colorspace_key], config_items) + converted_color_data[colorspace_key] = color_data + else: + converted_color_data[colorspace_key] = None + + # add colorspace to config data + if converted_color_data["working_colorspace"]: + config_data["colorspace"] = ( + converted_color_data["working_colorspace"]["name"] + ) # create lut representation data lut_repre = { @@ -58,12 +68,11 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, instance.data.update({ "representations": [lut_repre], "source": file_url, + "ocioLookWorkingSpace": converted_color_data["working_colorspace"], "ocioLookItems": [ { "name": lut_repre_name, "ext": ext.lstrip("."), - "working_colorspace": converted_color_data[ - "working_colorspace"], "input_colorspace": converted_color_data[ "input_colorspace"], "output_colorspace": converted_color_data[ @@ -72,7 +81,7 @@ class CollectColorspaceLook(pyblish.api.InstancePlugin, "interpolation": creator_attrs["interpolation"], "config_data": config_data } - ] + ], }) self.log.debug(pformat(instance.data)) diff --git a/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py index ffd877af1d..f94bbc7a49 100644 --- a/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py @@ -16,6 +16,7 @@ class ExtractColorspaceLook(publish.Extractor, def process(self, instance): ociolook_items = instance.data["ocioLookItems"] + ociolook_working_color = instance.data["ocioLookWorkingSpace"] staging_dir = self.staging_dir(instance) # create ociolook file attributes @@ -23,7 +24,8 @@ class ExtractColorspaceLook(publish.Extractor, ociolook_file_content = { "version": 1, "data": { - "ocioLookItems": ociolook_items + "ocioLookItems": ociolook_items, + "ocioLookWorkingSpace": ociolook_working_color } } diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py index ce7f8831fd..548ce9d15a 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py @@ -21,25 +21,54 @@ class ValidateColorspaceLook(pyblish.api.InstancePlugin, instance.data["instance_id"]) creator_defs = created_instance.creator_attribute_defs + ociolook_working_color = instance.data.get("ocioLookWorkingSpace") ociolook_items = instance.data.get("ocioLookItems", []) - for ociolook_item in ociolook_items: - self.validate_colorspace_set_attrs(ociolook_item, creator_defs) + creator_defs_by_key = {_def.key: _def.label for _def in creator_defs} - def validate_colorspace_set_attrs(self, ociolook_item, creator_defs): + not_set_keys = {} + if not ociolook_working_color: + not_set_keys["working_colorspace"] = creator_defs_by_key[ + "working_colorspace"] + + for ociolook_item in ociolook_items: + item_not_set_keys = self.validate_colorspace_set_attrs( + ociolook_item, creator_defs_by_key) + if item_not_set_keys: + not_set_keys[ociolook_item["name"]] = item_not_set_keys + + if not_set_keys: + message = ( + "Colorspace look attributes are not set: \n" + ) + for key, value in not_set_keys.items(): + if isinstance(value, list): + values_string = "\n\t- ".join(value) + message += f"\n\t{key}:\n\t- {values_string}" + else: + message += f"\n\t{value}" + + raise PublishValidationError( + title="Colorspace Look attributes", + message=message, + description=message + ) + + def validate_colorspace_set_attrs( + self, + ociolook_item, + creator_defs_by_key + ): """Validate colorspace look attributes""" self.log.debug(f"Validate colorspace look attributes: {ociolook_item}") - self.log.debug(f"Creator defs: {creator_defs}") check_keys = [ - "working_colorspace", "input_colorspace", "output_colorspace", "direction", "interpolation" ] - creator_defs_by_key = {_def.key: _def.label for _def in creator_defs} not_set_keys = [] for key in check_keys: @@ -57,13 +86,4 @@ class ValidateColorspaceLook(pyblish.api.InstancePlugin, ) not_set_keys.append(def_label) - if not_set_keys: - message = ( - "Colorspace look attributes are not set: " - f"{', '.join(not_set_keys)}" - ) - raise PublishValidationError( - title="Colorspace Look attributes", - message=message, - description=message - ) + return not_set_keys diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 82d9b17a37..9f720f6ae9 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -546,6 +546,9 @@ def convert_colorspace_enumerator_item( Returns: dict: colorspace data """ + if "::" not in colorspace_enum_item: + return None + # split string with `::` separator and set first as key and second as value item_type, item_name = colorspace_enum_item.split("::") From c56560283ab72735718cba46e263887efc7b7d99 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 18 Oct 2023 15:21:58 +0200 Subject: [PATCH 0663/1224] hound catches --- openpype/hosts/nuke/plugins/load/load_ociolook.py | 5 +++-- .../traypublisher/plugins/publish/collect_colorspace_look.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 3413b85749..18c8cdba35 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -184,8 +184,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): ) if not item_lut_file: raise ValueError( - "File with extension '{}' not found in directory".format( - extension)) + "File with extension '{}' not " + "found in directory".format(extension) + ) item_lut_path = os.path.join( dir_path, item_lut_file).replace("\\", "/") diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py index f259b120e9..6aede099bf 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -1,4 +1,3 @@ -from math import e import os from pprint import pformat import pyblish.api From 519a99adff9e7b5735be59cc62bf4f4dc75bd7ca Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 21:39:42 +0800 Subject: [PATCH 0664/1224] bug fix some of the unsupported arguments --- openpype/hosts/max/api/lib.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8c0bacf792..fc74d78f05 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -331,6 +331,9 @@ def viewport_camera(camera): """ original = rt.viewport.getCamera() has_autoplay = rt.preferences.playPreviewWhenDone + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + orig_preset = viewport_setting.ViewportPreset if not original: # if there is no original camera # use the current camera as original @@ -343,6 +346,8 @@ def viewport_camera(camera): finally: rt.viewport.setCamera(original) rt.preferences.playPreviewWhenDone = has_autoplay + viewport_setting.ViewportPreset = orig_preset + rt.completeRedraw() @contextlib.contextmanager @@ -604,6 +609,15 @@ def publish_review_animation(instance, filepath, visual_style_preset = instance.data.get("visualStyleMode") if visual_style_preset == "Realistic": visual_style_preset = "defaultshading" + elif visual_style_preset == "Shaded": + visual_style_preset = "defaultshading" + log.warning( + "'Shaded' Mode not supported in " + "preview animation in Max 2024..\n\n" + "Using 'defaultshading' instead") + + elif visual_style_preset == "ConsistentColors": + visual_style_preset = "flatcolor" else: visual_style_preset = visual_style_preset.lower() # new argument exposed for Max 2024 for visual style From 1ecf502e9a25538d55d47c8af12d2053654279b3 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 18 Oct 2023 17:09:20 +0300 Subject: [PATCH 0665/1224] add collectors section in Houdini settings --- .../projects_schema/schemas/schema_houdini_publish.json | 4 ++++ server_addon/houdini/server/settings/publish_plugins.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index d030b3bdd3..7bdb643dbb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -4,6 +4,10 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type":"label", + "label":"Collectors" + }, { "type": "dict", "collapsible": true, diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 76030bdeea..a19fb4bca9 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -158,7 +158,8 @@ class CollectRopFrameRangeModel(BaseSettingsModel): ignore start and end handles specified in the asset data for publish instances """ - use_asset_handles: bool = Field(title="Use asset handles") + use_asset_handles: bool = Field( + title="Use asset handles") class BasicValidateModel(BaseSettingsModel): @@ -170,7 +171,8 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): CollectRopFrameRange: CollectRopFrameRangeModel = Field( default_factory=CollectRopFrameRangeModel, - title="Collect Rop Frame Range." + title="Collect Rop Frame Range.", + section="Collectors" ) ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( default_factory=ValidateWorkfilePathsModel, From 520ff86cd044c15328fa936cc452301836626943 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 18 Oct 2023 17:32:10 +0300 Subject: [PATCH 0666/1224] bump houdini addon version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 1276d0254f..0a8da88258 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" From 6ed7ebebceb47fc2dc829b6b9cd96a8eff7fa509 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 18 Oct 2023 16:32:29 +0200 Subject: [PATCH 0667/1224] 'NumberAttrWidget' shows 'Multiselection' label on multiselection --- openpype/tools/attribute_defs/widgets.py | 66 ++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index d9c55f4a64..738f036b80 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -20,6 +20,7 @@ from openpype.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + ClickableFrame, ) from openpype.widgets.nice_checkbox import NiceCheckbox @@ -251,6 +252,30 @@ class LabelAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) +class ClickableLineEdit(QtWidgets.QLineEdit): + clicked = QtCore.Signal() + + def __init__(self, text, parent): + super(ClickableLineEdit, self).__init__(parent) + self.setText(text) + self.setReadOnly(True) + + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(ClickableLineEdit, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(ClickableLineEdit, self).mouseReleaseEvent(event) + + class NumberAttrWidget(_BaseAttrDefWidget): def _ui_init(self): decimals = self.attr_def.decimals @@ -271,19 +296,23 @@ class NumberAttrWidget(_BaseAttrDefWidget): QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons ) + multisel_widget = ClickableLineEdit("< Multiselection >", self) + input_widget.valueChanged.connect(self._on_value_change) + multisel_widget.clicked.connect(self._on_multi_click) self._input_widget = input_widget + self._multisel_widget = multisel_widget + self._last_multivalue = None self.main_layout.addWidget(input_widget, 0) - - def _on_value_change(self, new_value): - self.value_changed.emit(new_value, self.attr_def.id) + self.main_layout.addWidget(multisel_widget, 0) def current_value(self): return self._input_widget.value() def set_value(self, value, multivalue=False): + self._last_multivalue = None if multivalue: set_value = set(value) if None in set_value: @@ -291,13 +320,42 @@ class NumberAttrWidget(_BaseAttrDefWidget): set_value.add(self.attr_def.default) if len(set_value) > 1: - self._input_widget.setSpecialValueText("Multiselection") + self._last_multivalue = next(iter(set_value), None) + self._set_multiselection_visible(True) return value = tuple(set_value)[0] + self._set_multiselection_visible(False, False) + if self.current_value != value: self._input_widget.setValue(value) + def _on_value_change(self, new_value): + self.value_changed.emit(new_value, self.attr_def.id) + + def _on_multi_click(self): + self._set_multiselection_visible(False) + + def _set_multiselection_visible(self, visible, change_focus=True): + self._input_widget.setVisible(not visible) + self._multisel_widget.setVisible(visible) + if visible: + return + + # Change value once user clicked on the input field + if self._last_multivalue is None: + value = self.attr_def.default + else: + value = self._last_multivalue + self._input_widget.setValue(value) + if not change_focus: + return + # Change focus to input field and move cursor to the end + self._input_widget.setFocus(QtCore.Qt.MouseFocusReason) + line_edit = self._input_widget.lineEdit() + if line_edit is not None: + line_edit.setCursorPosition(len(line_edit.text())) + class TextAttrWidget(_BaseAttrDefWidget): def _ui_init(self): From 559750c07ce69e30a0d398a039a88bbe3f3d787c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 18 Oct 2023 17:42:51 +0300 Subject: [PATCH 0668/1224] bump houdini addon version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 1276d0254f..0a8da88258 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" From 52a086c2b19b9d6137a291827a67d29dc4942a43 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 23:10:37 +0800 Subject: [PATCH 0669/1224] integrate both functions into a function for extract preview action --- openpype/hosts/max/api/lib.py | 72 +++++++++++++------ .../max/plugins/publish/collect_review.py | 2 +- .../publish/extract_review_animation.py | 29 ++------ 3 files changed, 55 insertions(+), 48 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index fc74d78f05..4266db1e7f 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -347,15 +347,22 @@ def viewport_camera(camera): rt.viewport.setCamera(original) rt.preferences.playPreviewWhenDone = has_autoplay viewport_setting.ViewportPreset = orig_preset - rt.completeRedraw() @contextlib.contextmanager -def viewport_preference_setting(camera, - general_viewport, +def play_preview_when_done(has_autoplay): + current_playback = rt.preferences.playPreviewWhenDone + try: + rt.preferences.playPreviewWhenDone = has_autoplay + yield + finally: + rt.preferences.playPreviewWhenDone = current_playback + + +@contextlib.contextmanager +def viewport_preference_setting(general_viewport, nitrous_viewport, - vp_button_mgr, - preview_preferences): + vp_button_mgr): """Function to set viewport setting during context ***For Max Version < 2024 Args: @@ -366,12 +373,6 @@ def viewport_preference_setting(camera, vp_button_mgr (dict): Viewport button manager Setting preview_preferences (dict): Preview Preferences Setting """ - original_camera = rt.viewport.getCamera() - if not original_camera: - # if there is no original camera - # use the current camera as original - original_camera = rt.getNodeByName(camera) - review_camera = rt.getNodeByName(camera) orig_vp_grid = rt.viewport.getGridVisibility(1) orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() @@ -383,11 +384,8 @@ def viewport_preference_setting(camera, nitrous_viewport_original = { key: getattr(viewport_setting, key) for key in nitrous_viewport } - preview_preferences_original = { - key: getattr(rt.preferences, key) for key in preview_preferences - } + try: - rt.viewport.setCamera(review_camera) rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) for key, value in vp_button_mgr.items(): @@ -395,21 +393,15 @@ def viewport_preference_setting(camera, for key, value in nitrous_viewport.items(): if nitrous_viewport[key] != nitrous_viewport_original[key]: setattr(viewport_setting, key, value) - for key, value in preview_preferences.items(): - setattr(rt.preferences, key, value) yield finally: - rt.viewport.setCamera(review_camera) rt.viewport.setGridVisibility(1, orig_vp_grid) rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) for key, value in vp_button_mgr_original.items(): setattr(rt.ViewportButtonMgr, key, value) for key, value in nitrous_viewport_original.items(): setattr(viewport_setting, key, value) - for key, value in preview_preferences_original.items(): - setattr(rt.preferences, key, value) - rt.completeRedraw() def set_timeline(frameStart, frameEnd): @@ -638,6 +630,9 @@ def publish_review_animation(instance, filepath, viewport_texture_option = f"vpTexture:{viewport_texture}" job_args.append(viewport_texture_option) + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + job_str = " ".join(job_args) log.debug(job_str) @@ -659,8 +654,6 @@ def publish_preview_sequences(staging_dir, filename, ext (str): image extension """ # get the screenshot - rt.forceCompleteRedraw() - rt.enableSceneRedraw() resolution_percentage = float(percentSize) / 100 res_width = rt.renderWidth * resolution_percentage res_height = rt.renderHeight * resolution_percentage @@ -703,3 +696,36 @@ def publish_preview_sequences(staging_dir, filename, rt.exit() # clean up the cache rt.gc(delayed=True) + +def publish_preview_animation(instance, staging_dir, filepath, + startFrame, endFrame, review_camera): + """Publish Reivew Animation + + Args: + instance (pyblish.api.instance): Instance + staging_dir (str): staging directory + filepath (str): filepath + startFrame (int): start frame + endFrame (int): end frame + review_camera (str): viewport camera for + preview render + """ + with play_preview_when_done(False): + with viewport_camera(review_camera): + if int(get_max_version()) < 2024: + with viewport_preference_setting( + instance.data["general_viewport"], + instance.data["nitrous_viewport"], + instance.data["vp_btn_mgr"]): + percentSize = instance.data.get("percentSize") + ext = instance.data.get("imageFormat") + rt.completeRedraw() + publish_preview_sequences( + staging_dir, instance.name, + startFrame, endFrame, percentSize, ext) + else: + fps = instance.data["fps"] + rt.completeRedraw() + preview_arg = publish_review_animation( + instance, filepath, startFrame, endFrame, fps) + rt.execute(preview_arg) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 21f63a8c73..c3985a2ded 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -35,7 +35,7 @@ class CollectReview(pyblish.api.InstancePlugin, "percentSize": creator_attrs["percentSize"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], - "fps": instance.context.data["fps"], + "fps": instance.context.data["fps"] } if int(get_max_version()) >= 2024: diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index ccd641f619..27a86323eb 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -1,14 +1,7 @@ import os import pyblish.api -from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import ( - viewport_camera, - viewport_preference_setting, - get_max_version, - publish_review_animation, - publish_preview_sequences -) +from openpype.hosts.max.api.lib import publish_preview_animation class ExtractReviewAnimation(publish.Extractor): @@ -27,7 +20,6 @@ class ExtractReviewAnimation(publish.Extractor): filename = "{0}..{1}".format(instance.name, ext) start = int(instance.data["frameStart"]) end = int(instance.data["frameEnd"]) - fps = float(instance.data["fps"]) filepath = os.path.join(staging_dir, filename) filepath = filepath.replace("\\", "/") filenames = self.get_files( @@ -38,21 +30,10 @@ class ExtractReviewAnimation(publish.Extractor): " '%s' to '%s'" % (filename, staging_dir)) review_camera = instance.data["review_camera"] - if int(get_max_version()) < 2024: - with viewport_preference_setting(review_camera, - instance.data["general_viewport"], - instance.data["nitrous_viewport"], - instance.data["vp_btn_mgr"], - instance.data["preferences"]): - percentSize = instance.data.get("percentSize") - publish_preview_sequences( - staging_dir, instance.name, - start, end, percentSize, ext) - else: - with viewport_camera(review_camera): - preview_arg = publish_review_animation( - instance, filepath, start, end, fps) - rt.execute(preview_arg) + publish_preview_animation( + instance, staging_dir, + filepath, start, end, + review_camera) tags = ["review"] if not instance.data.get("keepImages"): From 3e75b9ec796791cdd19617e1dddf68db31420063 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 23:13:03 +0800 Subject: [PATCH 0670/1224] hound --- openpype/hosts/max/api/lib.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 4266db1e7f..2027c88214 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -697,6 +697,7 @@ def publish_preview_sequences(staging_dir, filename, # clean up the cache rt.gc(delayed=True) + def publish_preview_animation(instance, staging_dir, filepath, startFrame, endFrame, review_camera): """Publish Reivew Animation @@ -707,22 +708,21 @@ def publish_preview_animation(instance, staging_dir, filepath, filepath (str): filepath startFrame (int): start frame endFrame (int): end frame - review_camera (str): viewport camera for - preview render + review_camera (str): viewport camera for preview render """ with play_preview_when_done(False): with viewport_camera(review_camera): if int(get_max_version()) < 2024: - with viewport_preference_setting( + with viewport_preference_setting( instance.data["general_viewport"], instance.data["nitrous_viewport"], instance.data["vp_btn_mgr"]): - percentSize = instance.data.get("percentSize") - ext = instance.data.get("imageFormat") - rt.completeRedraw() - publish_preview_sequences( - staging_dir, instance.name, - startFrame, endFrame, percentSize, ext) + percentSize = instance.data.get("percentSize") + ext = instance.data.get("imageFormat") + rt.completeRedraw() + publish_preview_sequences( + staging_dir, instance.name, + startFrame, endFrame, percentSize, ext) else: fps = instance.data["fps"] rt.completeRedraw() From bb4778e71bff7ba54e017eb35f252c7a916c132b Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 18 Oct 2023 18:17:08 +0300 Subject: [PATCH 0671/1224] MinKiu Comments --- .../publish/collect_rop_frame_range.py | 27 ++++++++++++++----- .../plugins/publish/validate_frame_range.py | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index c9bc1766cb..c610deba40 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -41,11 +41,17 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return # Log artist friendly message about the collected frame range + frame_start=frame_data["frameStart"] + frame_end=frame_data["frameEnd"] + if attr_values.get("use_handles"): self.log.info( "Full Frame range with Handles " - "{0[frameStartHandle]} - {0[frameEndHandle]}\n" - .format(frame_data) + "[{frame_start_handle} - {frame_end_handle}]\n" + .format( + frame_start_handle=frame_data["frameStartHandle"], + frame_end_handle=frame_data["frameEndHandle"] + ) ) else: self.log.info( @@ -54,20 +60,27 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, ) self.log.info( - "Frame range {0[frameStart]} - {0[frameEnd]}" - .format(frame_data) + "Frame range [{frame_start} - {frame_end}]" + .format( + frame_start=frame_start, + frame_end=frame_end + ) ) if frame_data.get("byFrameStep", 1.0) != 1.0: - self.log.info("Frame steps {0[byFrameStep]}".format(frame_data)) + self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) instance.data.update(frame_data) # Add frame range to label if the instance has a frame range. label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( - "{0} [{1[frameStart]} - {1[frameEnd]}]" - .format(label, frame_data) + "{label} [{frame_start} - {frame_end}]" + .format( + label=label, + frame_start=frame_start, + frame_end=frame_end + ) ) @classmethod diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 2411d29e3e..b35ab62002 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -36,7 +36,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): "The frame range for the instance is invalid because " "the start frame is higher than the end frame.\n\nThis " "is likely due to asset handles being applied to your " - "instance or may be because the ROP node's start frame " + "instance or the ROP node's start frame " "is set higher than the end frame.\n\nIf your ROP frame " "range is correct and you do not want to apply asset " "handles make sure to disable Use asset handles on the " From f2ad5ee2536c8492363bc64b1a8110c37bd27ec3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 18 Oct 2023 18:19:07 +0300 Subject: [PATCH 0672/1224] resolve hound --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index c610deba40..91d5a5ef74 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -41,8 +41,8 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return # Log artist friendly message about the collected frame range - frame_start=frame_data["frameStart"] - frame_end=frame_data["frameEnd"] + frame_start = frame_data["frameStart"] + frame_end = frame_data["frameEnd"] if attr_values.get("use_handles"): self.log.info( From eae470977570aecb8cd26d248d56f4a5ebd4cdcd Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 23:39:47 +0800 Subject: [PATCH 0673/1224] refactored the preview animation publish --- openpype/hosts/max/api/lib.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 2027c88214..23a6ab0717 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -567,8 +567,8 @@ def get_plugins() -> list: return plugin_info_list -def publish_review_animation(instance, filepath, - start, end, fps): +def publish_review_animation(instance, staging_dir, start, + end, ext, fps): """Function to set up preview arguments in MaxScript. ****For 3dsMax 2024+ @@ -583,6 +583,9 @@ def publish_review_animation(instance, filepath, list: job arguments """ job_args = list() + filename = "{0}..{1}".format(instance.name, ext) + filepath = os.path.join(staging_dir, filename) + filepath = filepath.replace("\\", "/") default_option = f'CreatePreview filename:"{filepath}"' job_args.append(default_option) frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa @@ -698,13 +701,14 @@ def publish_preview_sequences(staging_dir, filename, rt.gc(delayed=True) -def publish_preview_animation(instance, staging_dir, filepath, - startFrame, endFrame, review_camera): - """Publish Reivew Animation +def publish_preview_animation( + instance, staging_dir, + startFrame, endFrame, + ext, review_camera): + """Render camera review animation Args: instance (pyblish.api.instance): Instance - staging_dir (str): staging directory filepath (str): filepath startFrame (int): start frame endFrame (int): end frame @@ -718,7 +722,6 @@ def publish_preview_animation(instance, staging_dir, filepath, instance.data["nitrous_viewport"], instance.data["vp_btn_mgr"]): percentSize = instance.data.get("percentSize") - ext = instance.data.get("imageFormat") rt.completeRedraw() publish_preview_sequences( staging_dir, instance.name, @@ -726,6 +729,7 @@ def publish_preview_animation(instance, staging_dir, filepath, else: fps = instance.data["fps"] rt.completeRedraw() - preview_arg = publish_review_animation( - instance, filepath, startFrame, endFrame, fps) + preview_arg = publish_review_animation(instance, staging_dir, + startFrame, endFrame, + ext, fps) rt.execute(preview_arg) From f650ecc20e0892ee9c72a1ae6160e443e9d0b726 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 23:58:33 +0800 Subject: [PATCH 0674/1224] refactored the preview animation publish --- openpype/hosts/max/api/lib.py | 8 ++++++-- .../hosts/max/plugins/publish/extract_review_animation.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 23a6ab0717..9c5eccb215 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -703,8 +703,8 @@ def publish_preview_sequences(staging_dir, filename, def publish_preview_animation( instance, staging_dir, - startFrame, endFrame, - ext, review_camera): + ext, review_camera, + startFrame=None, endFrame=None): """Render camera review animation Args: @@ -714,6 +714,10 @@ def publish_preview_animation( endFrame (int): end frame review_camera (str): viewport camera for preview render """ + if start_frame is None: + start_frame = int(rt.animationRange.start) + if end_frame is None: + end_frame = int(rt.animationRange.end) with play_preview_when_done(False): with viewport_camera(review_camera): if int(get_max_version()) < 2024: diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 27a86323eb..d57ed44d65 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -32,8 +32,8 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] publish_preview_animation( instance, staging_dir, - filepath, start, end, - review_camera) + ext, review_camera, + startFrame=start, endFrame=end) tags = ["review"] if not instance.data.get("keepImages"): From f96b7dbb198f0b5ca23abf5470ca72829039bc5b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 18 Oct 2023 23:59:39 +0800 Subject: [PATCH 0675/1224] hound --- openpype/hosts/max/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 9c5eccb215..eb6cd3cabc 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -714,10 +714,10 @@ def publish_preview_animation( endFrame (int): end frame review_camera (str): viewport camera for preview render """ - if start_frame is None: - start_frame = int(rt.animationRange.start) - if end_frame is None: - end_frame = int(rt.animationRange.end) + if startFrame is None: + startFrame = int(rt.animationRange.start) + if endFrame is None: + endFrame = int(rt.animationRange.end) with play_preview_when_done(False): with viewport_camera(review_camera): if int(get_max_version()) < 2024: From 0e22a2ef3cdb32ee013bd0ea51c4685768d53997 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 19:25:32 +0200 Subject: [PATCH 0676/1224] Remove `setParms` call since it's responsibility of `self.imprint` to set the values --- openpype/hosts/houdini/api/plugin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index d79ccc71bd..72565f7211 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -250,14 +250,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): key: changes[key].new_value for key in changes.changed_keys } - # Update ParmTemplates + # Update parm templates and values self.imprint( instance_node, new_values, update=True ) - # Update values - instance_node.setParms(new_values) def imprint(self, node, values, update=False): # Never store instance node and instance id since that data comes From a2c5934a1e2c9b88d445762a623647d8c80fdc08 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 22:14:13 +0200 Subject: [PATCH 0677/1224] Fix updating parms of same name that user had taken off of default value manually + refactor deprecated `Node.replaceSpareParmTuple` to use `ParmTemplateGroup.replace` instead --- openpype/hosts/houdini/api/lib.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index f258dda36e..2440ded6ad 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -369,16 +369,18 @@ def imprint(node, data, update=False): # is for some reason lost every call to `appendToFolder()` parm_folder = parm_group.findFolder("Extra") + for parm_template in update_parms: + parm_group.replace(parm_template.name(), parm_template) + + # When replacing a parm with a parm of the same name it preserves its + # value if before the replacement the parm was not at the default, + # because it has a value override set. Since we're trying to update the + # parm by using the new value as `default` we enforce the parm is at + # default state + node.parm(parm_template.name()).revertToDefaults() + node.setParmTemplateGroup(parm_group) - # TODO: Updating is done here, by calling probably deprecated functions. - # This needs to be addressed in the future. - if not update_parms: - return - - for parm in update_parms: - node.replaceSpareParmTuple(parm.name(), parm) - def lsattr(attr, value=None, root="/"): """Return nodes that have `attr` From e001b2632a69a32bcc07dbdf5d16c5ed70927bbd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 22:16:18 +0200 Subject: [PATCH 0678/1224] Refactor `parm` to `parm_template` to clarify variable refers to a parm template --- openpype/hosts/houdini/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 2440ded6ad..a777d15581 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -339,7 +339,7 @@ def imprint(node, data, update=False): if value is None: continue - parm = get_template_from_value(key, value) + parm_template = get_template_from_value(key, value) if key in current_parms: if node.evalParm(key) == data[key]: @@ -348,10 +348,10 @@ def imprint(node, data, update=False): log.debug(f"{key} already exists on {node}") else: log.debug(f"replacing {key}") - update_parms.append(parm) + update_parms.append(parm_template) continue - templates.append(parm) + templates.append(parm_template) parm_group = node.parmTemplateGroup() parm_folder = parm_group.findFolder("Extra") From f93e27ac41fbcbe20e4cb1d14034ef781eaedbc4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 22:23:10 +0200 Subject: [PATCH 0679/1224] Simplify logic --- openpype/hosts/houdini/api/lib.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a777d15581..7263a79e53 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -363,11 +363,10 @@ def imprint(node, data, update=False): parm_folder.setParmTemplates(templates) parm_group.append(parm_folder) else: + # Add to parm folder instance, then replace with updated one in group for template in templates: - parm_group.appendToFolder(parm_folder, template) - # this is needed because the pointer to folder - # is for some reason lost every call to `appendToFolder()` - parm_folder = parm_group.findFolder("Extra") + parm_folder.addParmTemplate(template) + parm_group.replace(parm_folder.name(), parm_folder) for parm_template in update_parms: parm_group.replace(parm_template.name(), parm_template) From b2a86e22d053986630fb7173ede638bc4ff8d57d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 22:26:48 +0200 Subject: [PATCH 0680/1224] Simplify --- 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 7263a79e53..a91da396ec 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -342,7 +342,7 @@ def imprint(node, data, update=False): parm_template = get_template_from_value(key, value) if key in current_parms: - if node.evalParm(key) == data[key]: + if node.evalParm(key) == value: continue if not update: log.debug(f"{key} already exists on {node}") From 3506c810b17916ef93c12e84d69737773f1939d8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Oct 2023 22:31:49 +0200 Subject: [PATCH 0681/1224] Refactor variable names to refer to parm templates as opposed to parms + do nothing if both no new and no update parms to process --- openpype/hosts/houdini/api/lib.py | 42 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a91da396ec..3031e2d2bd 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -332,8 +332,8 @@ def imprint(node, data, update=False): return current_parms = {p.name(): p for p in node.spareParms()} - update_parms = [] - templates = [] + update_parm_templates = [] + new_parm_templates = [] for key, value in data.items(): if value is None: @@ -348,27 +348,35 @@ def imprint(node, data, update=False): log.debug(f"{key} already exists on {node}") else: log.debug(f"replacing {key}") - update_parms.append(parm_template) + update_parm_templates.append(parm_template) continue - templates.append(parm_template) + new_parm_templates.append(parm_template) + + if not new_parm_templates and not update_parm_templates: + return parm_group = node.parmTemplateGroup() - parm_folder = parm_group.findFolder("Extra") - # if folder doesn't exist yet, create one and append to it, - # else append to existing one - if not parm_folder: - parm_folder = hou.FolderParmTemplate("folder", "Extra") - parm_folder.setParmTemplates(templates) - parm_group.append(parm_folder) - else: - # Add to parm folder instance, then replace with updated one in group - for template in templates: - parm_folder.addParmTemplate(template) - parm_group.replace(parm_folder.name(), parm_folder) + # Add new parm templates + if new_parm_templates: + parm_folder = parm_group.findFolder("Extra") - for parm_template in update_parms: + # if folder doesn't exist yet, create one and append to it, + # else append to existing one + if not parm_folder: + parm_folder = hou.FolderParmTemplate("folder", "Extra") + parm_folder.setParmTemplates(new_parm_templates) + parm_group.append(parm_folder) + else: + # Add to parm template folder instance then replace with updated + # one in parm template group + for template in new_parm_templates: + parm_folder.addParmTemplate(template) + parm_group.replace(parm_folder.name(), parm_folder) + + # Update existing parm templates + for parm_template in update_parm_templates: parm_group.replace(parm_template.name(), parm_template) # When replacing a parm with a parm of the same name it preserves its From aee1ac1be9690a0bcf4eb4ea62a35342edd90dc8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 13:39:14 +0800 Subject: [PATCH 0682/1224] clean up the code in collector and extractor --- .../max/plugins/create/create_tycache.py | 22 ---------- .../publish/collect_tycache_attributes.py | 35 ++++++++-------- .../max/plugins/publish/extract_tycache.py | 41 ++++++++----------- .../plugins/publish/validate_tyflow_data.py | 2 +- 4 files changed, 33 insertions(+), 67 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py index c48094028a..92d12e012f 100644 --- a/openpype/hosts/max/plugins/create/create_tycache.py +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Creator plugin for creating TyCache.""" from openpype.hosts.max.api import plugin -from openpype.lib import EnumDef class CreateTyCache(plugin.MaxCreator): @@ -10,24 +9,3 @@ class CreateTyCache(plugin.MaxCreator): label = "TyCache" family = "tycache" icon = "gear" - - def create(self, subset_name, instance_data, pre_create_data): - instance_data["tycache_type"] = pre_create_data.get( - "tycache_type") - super(CreateTyCache, self).create( - subset_name, - instance_data, - pre_create_data) - - def get_pre_create_attr_defs(self): - attrs = super(CreateTyCache, self).get_pre_create_attr_defs() - - tycache_format_enum = ["tycache", "tycachespline"] - - return attrs + [ - - EnumDef("tycache_type", - tycache_format_enum, - default="tycache", - label="TyCache Type") - ] diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index fa27a9a9d6..779b4c1b7e 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -14,22 +14,19 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, families = ["tycache"] def process(self, instance): - all_tyc_attributes_dict = {} attr_values = self.get_attr_values_from_data(instance.data) - tycache_boolean_attributes = attr_values.get("all_tyc_attrs") - if tycache_boolean_attributes: - for attrs in tycache_boolean_attributes: - all_tyc_attributes_dict[attrs] = True - tyc_layer_attr = attr_values.get("tycache_layer") - if tyc_layer_attr: - all_tyc_attributes_dict["tycacheLayer"] = ( - tyc_layer_attr) - tyc_objname_attr = attr_values.get("tycache_objname") - if tyc_objname_attr: - all_tyc_attributes_dict["tycache_objname"] = ( - tyc_objname_attr) + attributes = {} + for attr_key in attr_values.get("tycacheAttributes", []): + attributes[attr_key] = True + + for key in ["tycacheLayer", "tycacheObjectName"]: + attributes[key] = attr_values.get(key, "") + + # Collect the selected channel data before exporting + instance.data["tyc_attrs"] = attributes self.log.debug( - f"Found tycache attributes: {all_tyc_attributes_dict}") + f"Found tycache attributes: {attributes}" + ) @classmethod def get_attribute_defs(cls): @@ -63,17 +60,17 @@ class CollectTyCacheData(pyblish.api.InstancePlugin, "tycacheChanMaterials", "tycacheCreateObjectIfNotCreated"] return [ - EnumDef("all_tyc_attrs", + EnumDef("tycacheAttributes", tyc_attr_enum, default=tyc_default_attrs, multiselection=True, label="TyCache Attributes"), - TextDef("tycache_layer", + TextDef("tycacheLayer", label="TyCache Layer", tooltip="Name of tycache layer", - default=""), - TextDef("tycache_objname", + default="$(tyFlowLayer)"), + TextDef("tycacheObjectName", label="TyCache Object Name", tooltip="TyCache Object Name", - default="") + default="$(tyFlowName)_tyCache") ] diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index a787080776..9262219b7a 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -13,9 +13,9 @@ class ExtractTyCache(publish.Extractor): Notes: - TyCache only works for TyFlow Pro Plugin. - Args: - self.export_particle(): sets up all job arguments for attributes - to be exported in MAXscript + Methods: + self.get_export_particles_job_args(): sets up all job arguments + for attributes to be exported in MAXscript self.get_operators(): get the export_particle operator @@ -30,7 +30,7 @@ class ExtractTyCache(publish.Extractor): def process(self, instance): # TODO: let user decide the param - start = int(instance.context.data.get("frameStart")) + start = int(instance.context.data["frameStart"]) end = int(instance.context.data.get("frameEnd")) self.log.info("Extracting Tycache...") @@ -42,17 +42,11 @@ class ExtractTyCache(publish.Extractor): with maintained_selection(): job_args = None - has_tyc_spline = ( - True - if instance.data["tycache_type"] == "tycachespline" - else False - ) if instance.data["tycache_type"] == "tycache": - job_args = self.export_particle( + job_args = self.get_export_particles_job_args( instance.data["members"], start, end, path, - additional_attributes, - tycache_spline_enabled=has_tyc_spline) + additional_attributes) for job in job_args: rt.Execute(job) representations = instance.data.setdefault("representations", []) @@ -66,7 +60,7 @@ class ExtractTyCache(publish.Extractor): self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") # Get the tyMesh filename for extraction - mesh_filename = "{}__tyMesh.tyc".format(instance.name) + mesh_filename = f"{instance.name}__tyMesh.tyc" mesh_repres = { 'name': 'tyMesh', 'ext': 'tyc', @@ -90,7 +84,7 @@ class ExtractTyCache(publish.Extractor): e.g. tycacheMain__tyPart_00000.tyc Args: - instance (str): instance. + instance (pyblish.api.Instance): instance. start_frame (int): Start frame. end_frame (int): End frame. @@ -101,13 +95,12 @@ class ExtractTyCache(publish.Extractor): filenames = [] # should we include frame 0 ? for frame in range(int(start_frame), int(end_frame) + 1): - filename = "{}__tyPart_{:05}.tyc".format(instance.name, frame) + filename = f"{instance.name}__tyPart_{frame:05}.tyc" filenames.append(filename) return filenames - def export_particle(self, members, start, end, - filepath, additional_attributes, - tycache_spline_enabled=False): + def get_export_particles_job_args(self, members, start, end, + filepath, additional_attributes): """Sets up all job arguments for attributes. Those attributes are to be exported in MAX Script. @@ -117,6 +110,8 @@ class ExtractTyCache(publish.Extractor): start (int): Start frame. end (int): End frame. filepath (str): Output path of the TyCache file. + additional_attributes (dict): channel attributes data + which needed to be exported Returns: list of arguments for MAX Script. @@ -125,12 +120,7 @@ class ExtractTyCache(publish.Extractor): job_args = [] opt_list = self.get_operators(members) for operator in opt_list: - if tycache_spline_enabled: - export_mode = f'{operator}.exportMode=3' - job_args.append(export_mode) - else: - export_mode = f'{operator}.exportMode=2' - job_args.append(export_mode) + job_args.append(f"{operator}.exportMode=2") start_frame = f"{operator}.frameStart={start}" job_args.append(start_frame) end_frame = f"{operator}.frameEnd={end}" @@ -192,6 +182,7 @@ class ExtractTyCache(publish.Extractor): if isinstance(value, bool): tyc_attribute = f"{operator}.{key}=True" elif isinstance(value, str): - tyc_attribute = f"{operator}.{key}={value}" + tyc_attribute = f'{operator}.{key}="{value}"' additional_args.append(tyc_attribute) + self.log.debug(additional_args) return additional_args diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index a359100e6e..4b2bf975ee 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -40,7 +40,7 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): and editable mesh(es) Args: - instance (str): instance node + instance (pyblish.api.Instance): instance Returns: invalid(list): list of invalid nodes which are not From 96cc3dd778d3b1b7f3c4aba01f5b2c64ee290b6c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 13:45:50 +0800 Subject: [PATCH 0683/1224] clean up the code in the extractor --- openpype/hosts/max/plugins/publish/extract_tycache.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 9262219b7a..33dde03667 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -41,12 +41,10 @@ class ExtractTyCache(publish.Extractor): additional_attributes = instance.data.get("tyc_attrs", {}) with maintained_selection(): - job_args = None - if instance.data["tycache_type"] == "tycache": - job_args = self.get_export_particles_job_args( - instance.data["members"], - start, end, path, - additional_attributes) + job_args = self.get_export_particles_job_args( + instance.data["members"], + start, end, path, + additional_attributes) for job in job_args: rt.Execute(job) representations = instance.data.setdefault("representations", []) From 3797ad5bb7cf2cfc578534e423ac956c5994a0a5 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 11:14:05 +0300 Subject: [PATCH 0684/1224] BigRoy's comments - better logging and removing unnecessary logic --- openpype/hosts/houdini/api/lib.py | 17 ++++------ .../publish/collect_rop_frame_range.py | 34 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 58be3c3836..70bc107d7f 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -559,7 +559,7 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node, asset_data=None, log=None): +def get_frame_data(node, handle_start=0, handle_end=0, log=None): """Get the frame data: start frame, end frame and steps. Args: @@ -569,8 +569,6 @@ def get_frame_data(node, asset_data=None, log=None): dict: frame data for start, end and steps. """ - if asset_data is None: - asset_data = {} if log is None: log = self.log @@ -587,21 +585,20 @@ def get_frame_data(node, asset_data=None, log=None): data["frameStartHandle"] = hou.intFrame() data["frameEndHandle"] = hou.intFrame() data["byFrameStep"] = 1.0 - data["handleStart"] = 0 - data["handleEnd"] = 0 + log.info( - "Node '{}' has 'Render current frame' set. \n" - "Asset Handles are ignored. \n" + "Node '{}' has 'Render current frame' set.\n" + "Asset Handles are ignored.\n" "frameStart and frameEnd are set to the " - "current frame".format(node.path()) + "current frame.".format(node.path()) ) else: data["frameStartHandle"] = int(node.evalParm("f1")) data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") - data["handleStart"] = asset_data.get("handleStart", 0) - data["handleEnd"] = asset_data.get("handleEnd", 0) + data["handleStart"] = handle_start + data["handleEnd"] = handle_end data["frameStart"] = data["frameStartHandle"] + data["handleStart"] data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 91d5a5ef74..f34b1faa77 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -4,11 +4,11 @@ import hou # noqa import pyblish.api from openpype.lib import BoolDef from openpype.hosts.houdini.api import lib -from openpype.pipeline import OptionalPyblishPluginMixin +from openpype.pipeline import OpenPypePyblishPluginMixin class CollectRopFrameRange(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): + OpenPypePyblishPluginMixin): """Collect all frames which would be saved from the ROP nodes""" @@ -28,14 +28,20 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return ropnode = hou.node(node_path) - asset_data = instance.context.data["assetEntity"]["data"] attr_values = self.get_attr_values_from_data(instance.data) - if not attr_values.get("use_handles"): - asset_data["handleStart"] = 0 - asset_data["handleEnd"] = 0 - frame_data = lib.get_frame_data(ropnode, asset_data, self.log) + if attr_values.get("use_handles", self.use_asset_handles): + asset_data = instance.context.data["assetEntity"]["data"] + handle_start = asset_data.get("handleStart", 0) + handle_end = asset_data.get("handleEnd", 0) + else: + handle_start = 0 + handle_end = 0 + + frame_data = lib.get_frame_data( + ropnode, handle_start, handle_end, self.log + ) if not frame_data: return @@ -47,26 +53,18 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, if attr_values.get("use_handles"): self.log.info( "Full Frame range with Handles " - "[{frame_start_handle} - {frame_end_handle}]\n" + "[{frame_start_handle} - {frame_end_handle}]" .format( frame_start_handle=frame_data["frameStartHandle"], frame_end_handle=frame_data["frameEndHandle"] ) ) else: - self.log.info( + self.log.debug( "Use handles is deactivated for this instance, " - "start and end handles are set to 0.\n" + "start and end handles are set to 0." ) - self.log.info( - "Frame range [{frame_start} - {frame_end}]" - .format( - frame_start=frame_start, - frame_end=frame_end - ) - ) - if frame_data.get("byFrameStep", 1.0) != 1.0: self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) From e3111239a0e3e0b6489f1d9daac546e49ba0c849 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:48:57 +0200 Subject: [PATCH 0685/1224] trigger 'selection_changed' signal to propagate selection change (#5793) --- openpype/tools/publisher/widgets/card_view_widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index eae8e0420a..5cdd429cd4 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -797,6 +797,7 @@ class InstanceCardView(AbstractInstanceView): widget.set_active(value) else: self._select_item_clear(instance_id, group_name, instance_widget) + self.selection_changed.emit() self.active_changed.emit() def _on_widget_selection(self, instance_id, group_name, selection_type): From e17ffd1c3d01a42f2655791d2263edf516393f04 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 12:05:49 +0300 Subject: [PATCH 0686/1224] BigRoy's comment - better logging --- .../plugins/publish/collect_rop_frame_range.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index f34b1faa77..875dd2da8a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -51,7 +51,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, frame_end = frame_data["frameEnd"] if attr_values.get("use_handles"): - self.log.info( + self.log.debug( "Full Frame range with Handles " "[{frame_start_handle} - {frame_end_handle}]" .format( @@ -65,6 +65,17 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, "start and end handles are set to 0." ) + message = "Frame range [{frame_start} - {frame_end}]".format( + frame_start=frame_start, + frame_end=frame_end + ) + if handle_start or handle_end: + message += " with handles [{handle_start}]-[{handle_end}]".format( + handle_start=handle_start, + handle_end=handle_end + ) + self.log.info(message) + if frame_data.get("byFrameStep", 1.0) != 1.0: self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) From b3078ed40f737b3b191898f34303df3b632de8e4 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 12:08:00 +0300 Subject: [PATCH 0687/1224] resolve hound --- .../houdini/plugins/publish/collect_rop_frame_range.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 875dd2da8a..6a1871afdc 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -66,9 +66,9 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, ) message = "Frame range [{frame_start} - {frame_end}]".format( - frame_start=frame_start, - frame_end=frame_end - ) + frame_start=frame_start, + frame_end=frame_end + ) if handle_start or handle_end: message += " with handles [{handle_start}]-[{handle_end}]".format( handle_start=handle_start, From cd11029665ad96f6d42b260d56b8d165b4aed299 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 Oct 2023 11:09:00 +0200 Subject: [PATCH 0688/1224] do not trigger value change signal when hiding multiselection label --- openpype/tools/attribute_defs/widgets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 738f036b80..e05db6bed0 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -347,7 +347,9 @@ class NumberAttrWidget(_BaseAttrDefWidget): value = self.attr_def.default else: value = self._last_multivalue + self._input_widget.blockSignals(True) self._input_widget.setValue(value) + self._input_widget.blockSignals(False) if not change_focus: return # Change focus to input field and move cursor to the end From e3a8050ced649fb8935190ec25cd265cda507fd5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 Oct 2023 11:10:11 +0200 Subject: [PATCH 0689/1224] show multiselection label back on lost focus --- openpype/tools/attribute_defs/widgets.py | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index e05db6bed0..46f8da317d 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -295,6 +295,8 @@ class NumberAttrWidget(_BaseAttrDefWidget): input_widget.setButtonSymbols( QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons ) + input_line_edit = input_widget.lineEdit() + input_widget.installEventFilter(self) multisel_widget = ClickableLineEdit("< Multiselection >", self) @@ -302,12 +304,23 @@ class NumberAttrWidget(_BaseAttrDefWidget): multisel_widget.clicked.connect(self._on_multi_click) self._input_widget = input_widget + self._input_line_edit = input_line_edit self._multisel_widget = multisel_widget self._last_multivalue = None + self._multivalue = False self.main_layout.addWidget(input_widget, 0) self.main_layout.addWidget(multisel_widget, 0) + def eventFilter(self, obj, event): + if ( + self._multivalue + and obj is self._input_widget + and event.type() == QtCore.QEvent.FocusOut + ): + self._set_multiselection_visible(True) + return False + def current_value(self): return self._input_widget.value() @@ -322,21 +335,24 @@ class NumberAttrWidget(_BaseAttrDefWidget): if len(set_value) > 1: self._last_multivalue = next(iter(set_value), None) self._set_multiselection_visible(True) + self._multivalue = True return value = tuple(set_value)[0] - self._set_multiselection_visible(False, False) + self._multivalue = False + self._set_multiselection_visible(False) if self.current_value != value: self._input_widget.setValue(value) def _on_value_change(self, new_value): + self._multivalue = False self.value_changed.emit(new_value, self.attr_def.id) def _on_multi_click(self): - self._set_multiselection_visible(False) + self._set_multiselection_visible(False, True) - def _set_multiselection_visible(self, visible, change_focus=True): + def _set_multiselection_visible(self, visible, change_focus=False): self._input_widget.setVisible(not visible) self._multisel_widget.setVisible(visible) if visible: @@ -354,9 +370,9 @@ class NumberAttrWidget(_BaseAttrDefWidget): return # Change focus to input field and move cursor to the end self._input_widget.setFocus(QtCore.Qt.MouseFocusReason) - line_edit = self._input_widget.lineEdit() - if line_edit is not None: - line_edit.setCursorPosition(len(line_edit.text())) + self._input_line_edit.setCursorPosition( + len(self._input_line_edit.text()) + ) class TextAttrWidget(_BaseAttrDefWidget): From 63e983412cc2e438153c093539a679c76486c0b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 Oct 2023 11:10:19 +0200 Subject: [PATCH 0690/1224] removed unused import --- openpype/tools/attribute_defs/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 46f8da317d..91b5b229de 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -20,7 +20,6 @@ from openpype.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, - ClickableFrame, ) from openpype.widgets.nice_checkbox import NiceCheckbox From 4418d1116477add6da68e33abeca492c669bba73 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 11:18:59 +0100 Subject: [PATCH 0691/1224] Code improvements from suggestions Co-authored-by: Roy Nieterau --- .../hosts/blender/plugins/load/load_abc.py | 21 +++++++------------ .../plugins/publish/collect_instances.py | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 531a820436..af28cff7fe 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -60,19 +60,14 @@ class CacheModelLoader(plugin.AssetLoader): imported = lib.get_selection() - empties = [obj for obj in imported if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if not empty.parent: - container = empty - break + # Use first EMPTY without parent as container + container = next( + (obj for obj in imported if obj.type == "EMPTY" and not obj.parent), + None + ) objects = [] if container: - # Children must be linked before parents, - # otherwise the hierarchy will break nodes = list(container.children) for obj in nodes: @@ -80,11 +75,9 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.objects.remove(container) + objects.extend(nodes) for obj in nodes: - objects.append(obj) - objects.extend(list(obj.children_recursive)) - - objects.reverse() + objects.extend(obj.children_recursive) else: for obj in imported: obj.parent = asset_group diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index c95d718187..ad2ce54147 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -51,7 +51,7 @@ class CollectInstances(pyblish.api.ContextPlugin): for group in asset_groups: instance = self.create_instance(context, group) members = [] - if type(group) == bpy.types.Collection: + if isinstance(group, bpy.types.Collection): members = list(group.objects) family = instance.data["family"] if family == "animation": From 0b69ce120a23cb2393f4a281030b8696d357d780 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 11:21:13 +0100 Subject: [PATCH 0692/1224] Hound fixes --- openpype/hosts/blender/plugins/load/load_abc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index af28cff7fe..73f08fcc98 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -62,7 +62,8 @@ class CacheModelLoader(plugin.AssetLoader): # Use first EMPTY without parent as container container = next( - (obj for obj in imported if obj.type == "EMPTY" and not obj.parent), + (obj for obj in imported + if obj.type == "EMPTY" and not obj.parent), None ) From 2c460ed64701a51812d02f67e27529978f8b4ad4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 18:23:55 +0800 Subject: [PATCH 0693/1224] move functions to preview_animation.py --- openpype/hosts/max/api/lib.py | 136 +++++--- openpype/hosts/max/api/preview_animation.py | 306 ++++++++++++++++++ .../max/plugins/publish/collect_review.py | 15 +- .../publish/extract_review_animation.py | 8 +- 4 files changed, 406 insertions(+), 59 deletions(-) create mode 100644 openpype/hosts/max/api/preview_animation.py diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index eb6cd3cabc..5e55daceb2 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -568,7 +568,7 @@ def get_plugins() -> list: def publish_review_animation(instance, staging_dir, start, - end, ext, fps): + end, ext, fps, viewport_options): """Function to set up preview arguments in MaxScript. ****For 3dsMax 2024+ @@ -578,6 +578,7 @@ def publish_review_animation(instance, staging_dir, start, start (int): startFrame end (int): endFrame fps (float): fps value + viewport_options (dict): viewport setting options Returns: list: job arguments @@ -590,48 +591,34 @@ def publish_review_animation(instance, staging_dir, start, job_args.append(default_option) frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa job_args.append(frame_option) - options = [ - "percentSize", "dspGeometry", "dspShapes", - "dspLights", "dspCameras", "dspHelpers", "dspParticles", - "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" - ] - for key in options: - enabled = instance.data.get(key) - if enabled: - job_args.append(f"{key}:{enabled}") + for key, value in viewport_options.items(): + if isinstance(value, bool): + if value: + job_args.append(f"{key}:{value}") - visual_style_preset = instance.data.get("visualStyleMode") - if visual_style_preset == "Realistic": - visual_style_preset = "defaultshading" - elif visual_style_preset == "Shaded": - visual_style_preset = "defaultshading" - log.warning( - "'Shaded' Mode not supported in " - "preview animation in Max 2024..\n\n" - "Using 'defaultshading' instead") - - elif visual_style_preset == "ConsistentColors": - visual_style_preset = "flatcolor" - else: - visual_style_preset = visual_style_preset.lower() - # new argument exposed for Max 2024 for visual style - visual_style_option = f"vpStyle:#{visual_style_preset}" - job_args.append(visual_style_option) - # new argument for pre-view preset exposed in Max 2024 - preview_preset = instance.data.get("viewportPreset") - if preview_preset == "Quality": - preview_preset = "highquality" - elif preview_preset == "Customize": - preview_preset = "userdefined" - else: - preview_preset = preview_preset.lower() - preview_preset_option = f"vpPreset:#{preview_preset}" - job_args.append(preview_preset_option) - viewport_texture = instance.data.get("vpTexture", True) - if viewport_texture: - viewport_texture_option = f"vpTexture:{viewport_texture}" - job_args.append(viewport_texture_option) + elif isinstance(value, str): + if key == "vpStyle": + if viewport_options[key] == "Realistic": + value = "defaultshading" + elif viewport_options[key] == "Shaded": + log.warning( + "'Shaded' Mode not supported in " + "preview animation in Max 2024..\n" + "Using 'defaultshading' instead") + value = "defaultshading" + elif viewport_options[key] == "ConsistentColors": + value = "flatcolor" + else: + value = value.lower() + elif key == "vpPreset": + if viewport_options[key] == "Quality": + value = "highquality" + elif viewport_options[key] == "Customize": + value = "userdefined" + else: + value = value.lower() + job_args.append(f"{key}: #{value}") auto_play_option = "autoPlay:false" job_args.append(auto_play_option) @@ -704,29 +691,34 @@ def publish_preview_sequences(staging_dir, filename, def publish_preview_animation( instance, staging_dir, ext, review_camera, - startFrame=None, endFrame=None): + startFrame=None, endFrame=None, + viewport_options=None): """Render camera review animation Args: instance (pyblish.api.instance): Instance filepath (str): filepath + review_camera (str): viewport camera for preview render startFrame (int): start frame endFrame (int): end frame - review_camera (str): viewport camera for preview render + viewport_options (dict): viewport setting options """ + if startFrame is None: startFrame = int(rt.animationRange.start) if endFrame is None: endFrame = int(rt.animationRange.end) + if viewport_options is None: + viewport_options = viewport_options_for_preview_animation() with play_preview_when_done(False): with viewport_camera(review_camera): if int(get_max_version()) < 2024: with viewport_preference_setting( - instance.data["general_viewport"], - instance.data["nitrous_viewport"], - instance.data["vp_btn_mgr"]): - percentSize = instance.data.get("percentSize") - rt.completeRedraw() + viewport_options["general_viewport"], + viewport_options["nitrous_viewport"], + viewport_options["vp_btn_mgr"]): + percentSize = viewport_options.get("percentSize", 100) + publish_preview_sequences( staging_dir, instance.name, startFrame, endFrame, percentSize, ext) @@ -735,5 +727,51 @@ def publish_preview_animation( rt.completeRedraw() preview_arg = publish_review_animation(instance, staging_dir, startFrame, endFrame, - ext, fps) + ext, fps, viewport_options) rt.execute(preview_arg) + + rt.completeRedraw() + +def viewport_options_for_preview_animation(): + """ + Function to store the default data of viewport options + Returns: + dict: viewport setting options + + """ + # viewport_options should be the dictionary + if int(get_max_version()) < 2024: + return { + "visualStyleMode": "defaultshading", + "viewportPreset": "highquality", + "percentSize": 100, + "vpTexture": False, + "dspGeometry": True, + "dspShapes": False, + "dspLights": False, + "dspCameras": False, + "dspHelpers": False, + "dspParticles": True, + "dspBones": False, + "dspBkg": True, + "dspGrid": False, + "dspSafeFrame":False, + "dspFrameNums": False + } + else: + viewport_options = {} + viewport_options.update({"percentSize": 100}) + general_viewport = { + "dspBkg": True, + "dspGrid": False + } + nitrous_viewport = { + "VisualStyleMode": "defaultshading", + "ViewportPreset": "highquality", + "UseTextureEnabled": False + } + viewport_options["general_viewport"] = general_viewport + viewport_options["nitrous_viewport"] = nitrous_viewport + viewport_options["vp_btn_mgr"] = { + "EnableButtons": False} + return viewport_options diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py new file mode 100644 index 0000000000..bb2f1410d4 --- /dev/null +++ b/openpype/hosts/max/api/preview_animation.py @@ -0,0 +1,306 @@ +import os +import logging +import contextlib +from pymxs import runtime as rt +from .lib import get_max_version + +log = logging.getLogger("openpype.hosts.max") + + +@contextlib.contextmanager +def play_preview_when_done(has_autoplay): + """Function to set preview playback option during + context + + Args: + has_autoplay (bool): autoplay during creating + preview animation + """ + current_playback = rt.preferences.playPreviewWhenDone + try: + rt.preferences.playPreviewWhenDone = has_autoplay + yield + finally: + rt.preferences.playPreviewWhenDone = current_playback + + +@contextlib.contextmanager +def viewport_camera(camera): + """Function to set viewport camera during context + ***For 3dsMax 2024+ + Args: + camera (str): viewport camera for review render + """ + original = rt.viewport.getCamera() + has_autoplay = rt.preferences.playPreviewWhenDone + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + orig_preset = viewport_setting.ViewportPreset + if not original: + # if there is no original camera + # use the current camera as original + original = rt.getNodeByName(camera) + review_camera = rt.getNodeByName(camera) + try: + rt.viewport.setCamera(review_camera) + rt.preferences.playPreviewWhenDone = False + yield + finally: + rt.viewport.setCamera(original) + rt.preferences.playPreviewWhenDone = has_autoplay + viewport_setting.ViewportPreset = orig_preset + + +@contextlib.contextmanager +def viewport_preference_setting(general_viewport, + nitrous_viewport, + vp_button_mgr): + """Function to set viewport setting during context + ***For Max Version < 2024 + Args: + camera (str): Viewport camera for review render + general_viewport (dict): General viewport setting + nitrous_viewport (dict): Nitrous setting for + preview animation + vp_button_mgr (dict): Viewport button manager Setting + preview_preferences (dict): Preview Preferences Setting + """ + orig_vp_grid = rt.viewport.getGridVisibility(1) + orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() + + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + vp_button_mgr_original = { + key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr + } + nitrous_viewport_original = { + key: getattr(viewport_setting, key) for key in nitrous_viewport + } + + try: + rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) + rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) + for key, value in vp_button_mgr.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_viewport.items(): + if nitrous_viewport[key] != nitrous_viewport_original[key]: + setattr(viewport_setting, key, value) + yield + + finally: + rt.viewport.setGridVisibility(1, orig_vp_grid) + rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) + for key, value in vp_button_mgr_original.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_viewport_original.items(): + setattr(viewport_setting, key, value) + +def publish_review_animation(instance, staging_dir, start, + end, ext, fps, viewport_options): + """Function to set up preview arguments in MaxScript. + ****For 3dsMax 2024+ + + Args: + instance (str): instance + filepath (str): output of the preview animation + start (int): startFrame + end (int): endFrame + fps (float): fps value + viewport_options (dict): viewport setting options + + Returns: + list: job arguments + """ + job_args = list() + filename = "{0}..{1}".format(instance.name, ext) + filepath = os.path.join(staging_dir, filename) + filepath = filepath.replace("\\", "/") + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa + job_args.append(frame_option) + + for key, value in viewport_options.items(): + if isinstance(value, bool): + if value: + job_args.append(f"{key}:{value}") + + elif isinstance(value, str): + if key == "vpStyle": + if viewport_options[key] == "Realistic": + value = "defaultshading" + elif viewport_options[key] == "Shaded": + log.warning( + "'Shaded' Mode not supported in " + "preview animation in Max 2024..\n" + "Using 'defaultshading' instead") + value = "defaultshading" + elif viewport_options[key] == "ConsistentColors": + value = "flatcolor" + else: + value = value.lower() + elif key == "vpPreset": + if viewport_options[key] == "Quality": + value = "highquality" + elif viewport_options[key] == "Customize": + value = "userdefined" + else: + value = value.lower() + job_args.append(f"{key}: #{value}") + + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + + job_str = " ".join(job_args) + log.debug(job_str) + + return job_str + + +def publish_preview_sequences(staging_dir, filename, + startFrame, endFrame, + percentSize, ext): + """publish preview animation by creating bitmaps + ***For 3dsMax Version <2024 + + Args: + staging_dir (str): staging directory + filename (str): filename + startFrame (int): start frame + endFrame (int): end frame + percentSize (int): percentage of the resolution + ext (str): image extension + """ + # get the screenshot + resolution_percentage = float(percentSize) / 100 + res_width = rt.renderWidth * resolution_percentage + res_height = rt.renderHeight * resolution_percentage + + viewportRatio = float(res_width / res_height) + + for i in range(startFrame, endFrame + 1): + rt.sliderTime = i + fname = "{}.{:04}.{}".format(filename, i, ext) + filepath = os.path.join(staging_dir, fname) + filepath = filepath.replace("\\", "/") + preview_res = rt.bitmap( + res_width, res_height, filename=filepath) + dib = rt.gw.getViewportDib() + dib_width = float(dib.width) + dib_height = float(dib.height) + renderRatio = float(dib_width / dib_height) + if viewportRatio <= renderRatio: + heightCrop = (dib_width / renderRatio) + topEdge = int((dib_height - heightCrop) / 2.0) + tempImage_bmp = rt.bitmap(dib_width, heightCrop) + src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) + else: + widthCrop = dib_height * renderRatio + leftEdge = int((dib_width - widthCrop) / 2.0) + tempImage_bmp = rt.bitmap(widthCrop, dib_height) + src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height) + rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) + + # copy the bitmap and close it + rt.copy(tempImage_bmp, preview_res) + rt.close(tempImage_bmp) + + rt.save(preview_res) + rt.close(preview_res) + + rt.close(dib) + + if rt.keyboard.escPressed: + rt.exit() + # clean up the cache + rt.gc(delayed=True) + + +def publish_preview_animation( + instance, staging_dir, + ext, review_camera, + startFrame=None, endFrame=None, + viewport_options=None): + """Render camera review animation + + Args: + instance (pyblish.api.instance): Instance + filepath (str): filepath + review_camera (str): viewport camera for preview render + startFrame (int): start frame + endFrame (int): end frame + viewport_options (dict): viewport setting options + """ + + if startFrame is None: + startFrame = int(rt.animationRange.start) + if endFrame is None: + endFrame = int(rt.animationRange.end) + if viewport_options is None: + viewport_options = viewport_options_for_preview_animation() + with play_preview_when_done(False): + with viewport_camera(review_camera): + if int(get_max_version()) < 2024: + with viewport_preference_setting( + viewport_options["general_viewport"], + viewport_options["nitrous_viewport"], + viewport_options["vp_btn_mgr"]): + percentSize = viewport_options.get("percentSize", 100) + + publish_preview_sequences( + staging_dir, instance.name, + startFrame, endFrame, percentSize, ext) + else: + fps = instance.data["fps"] + rt.completeRedraw() + preview_arg = publish_review_animation(instance, staging_dir, + startFrame, endFrame, + ext, fps, viewport_options) + rt.execute(preview_arg) + + rt.completeRedraw() + + +def viewport_options_for_preview_animation(): + """ + Function to store the default data of viewport options + Returns: + dict: viewport setting options + + """ + # viewport_options should be the dictionary + if int(get_max_version()) < 2024: + return { + "visualStyleMode": "defaultshading", + "viewportPreset": "highquality", + "percentSize": 100, + "vpTexture": False, + "dspGeometry": True, + "dspShapes": False, + "dspLights": False, + "dspCameras": False, + "dspHelpers": False, + "dspParticles": True, + "dspBones": False, + "dspBkg": True, + "dspGrid": False, + "dspSafeFrame":False, + "dspFrameNums": False + } + else: + viewport_options = {} + viewport_options.update({"percentSize": 100}) + general_viewport = { + "dspBkg": True, + "dspGrid": False + } + nitrous_viewport = { + "VisualStyleMode": "defaultshading", + "ViewportPreset": "highquality", + "UseTextureEnabled": False + } + viewport_options["general_viewport"] = general_viewport + viewport_options["nitrous_viewport"] = nitrous_viewport + viewport_options["vp_btn_mgr"] = { + "EnableButtons": False} + return viewport_options diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index c3985a2ded..904a4eab0f 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -32,7 +32,6 @@ class CollectReview(pyblish.api.InstancePlugin, "review_camera": camera_name, "imageFormat": creator_attrs["imageFormat"], "keepImages": creator_attrs["keepImages"], - "percentSize": creator_attrs["percentSize"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"] @@ -49,9 +48,10 @@ class CollectReview(pyblish.api.InstancePlugin, instance.data["colorspaceView"] = view_transform preview_data = { - "visualStyleMode": creator_attrs["visualStyleMode"], - "viewportPreset": creator_attrs["viewportPreset"], - "vpTexture": creator_attrs["vpTexture"], + "vpStyle": creator_attrs["visualStyleMode"], + "vpPreset": creator_attrs["viewportPreset"], + "percentSize": creator_attrs["percentSize"], + "vpTextures": creator_attrs["vpTexture"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), "dspLights": attr_values.get("dspLights"), @@ -66,6 +66,8 @@ class CollectReview(pyblish.api.InstancePlugin, } else: preview_data = {} + preview_data.update({ + "percentSize": creator_attrs["percentSize"]}) general_viewport = { "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid") @@ -80,9 +82,6 @@ class CollectReview(pyblish.api.InstancePlugin, preview_data["vp_btn_mgr"] = { "EnableButtons": False } - preview_data["preferences"] = { - "playPreviewWhenDone": False - } # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -91,7 +90,7 @@ class CollectReview(pyblish.api.InstancePlugin, burnin_members["focalLength"] = focal_length instance.data.update(general_preview_data) - instance.data.update(preview_data) + instance.data["viewport_options"] = preview_data @classmethod def get_attribute_defs(cls): diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index d57ed44d65..d2de981236 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -1,7 +1,9 @@ import os import pyblish.api from openpype.pipeline import publish -from openpype.hosts.max.api.lib import publish_preview_animation +from openpype.hosts.max.api.preview_animation import ( + publish_preview_animation +) class ExtractReviewAnimation(publish.Extractor): @@ -30,10 +32,12 @@ class ExtractReviewAnimation(publish.Extractor): " '%s' to '%s'" % (filename, staging_dir)) review_camera = instance.data["review_camera"] + viewport_options = instance.data.get("viewport_options", {}) publish_preview_animation( instance, staging_dir, ext, review_camera, - startFrame=start, endFrame=end) + startFrame=start, endFrame=end, + viewport_options=viewport_options) tags = ["review"] if not instance.data.get("keepImages"): From 0aa5b59384992ffc9f97554a57c606436f34fb3c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 18:24:15 +0800 Subject: [PATCH 0694/1224] move functions to preview_animation.py --- openpype/hosts/max/api/lib.py | 292 ---------------------------------- 1 file changed, 292 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 5e55daceb2..166a66ce48 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -322,88 +322,6 @@ def is_headless(): return rt.maxops.isInNonInteractiveMode() -@contextlib.contextmanager -def viewport_camera(camera): - """Function to set viewport camera during context - ***For 3dsMax 2024+ - Args: - camera (str): viewport camera for review render - """ - original = rt.viewport.getCamera() - has_autoplay = rt.preferences.playPreviewWhenDone - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - orig_preset = viewport_setting.ViewportPreset - if not original: - # if there is no original camera - # use the current camera as original - original = rt.getNodeByName(camera) - review_camera = rt.getNodeByName(camera) - try: - rt.viewport.setCamera(review_camera) - rt.preferences.playPreviewWhenDone = False - yield - finally: - rt.viewport.setCamera(original) - rt.preferences.playPreviewWhenDone = has_autoplay - viewport_setting.ViewportPreset = orig_preset - - -@contextlib.contextmanager -def play_preview_when_done(has_autoplay): - current_playback = rt.preferences.playPreviewWhenDone - try: - rt.preferences.playPreviewWhenDone = has_autoplay - yield - finally: - rt.preferences.playPreviewWhenDone = current_playback - - -@contextlib.contextmanager -def viewport_preference_setting(general_viewport, - nitrous_viewport, - vp_button_mgr): - """Function to set viewport setting during context - ***For Max Version < 2024 - Args: - camera (str): Viewport camera for review render - general_viewport (dict): General viewport setting - nitrous_viewport (dict): Nitrous setting for - preview animation - vp_button_mgr (dict): Viewport button manager Setting - preview_preferences (dict): Preview Preferences Setting - """ - orig_vp_grid = rt.viewport.getGridVisibility(1) - orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() - - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - vp_button_mgr_original = { - key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr - } - nitrous_viewport_original = { - key: getattr(viewport_setting, key) for key in nitrous_viewport - } - - try: - rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) - rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) - for key, value in vp_button_mgr.items(): - setattr(rt.ViewportButtonMgr, key, value) - for key, value in nitrous_viewport.items(): - if nitrous_viewport[key] != nitrous_viewport_original[key]: - setattr(viewport_setting, key, value) - yield - - finally: - rt.viewport.setGridVisibility(1, orig_vp_grid) - rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) - for key, value in vp_button_mgr_original.items(): - setattr(rt.ViewportButtonMgr, key, value) - for key, value in nitrous_viewport_original.items(): - setattr(viewport_setting, key, value) - - def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ @@ -565,213 +483,3 @@ def get_plugins() -> list: plugin_info_list.append(plugin_info) return plugin_info_list - - -def publish_review_animation(instance, staging_dir, start, - end, ext, fps, viewport_options): - """Function to set up preview arguments in MaxScript. - ****For 3dsMax 2024+ - - Args: - instance (str): instance - filepath (str): output of the preview animation - start (int): startFrame - end (int): endFrame - fps (float): fps value - viewport_options (dict): viewport setting options - - Returns: - list: job arguments - """ - job_args = list() - filename = "{0}..{1}".format(instance.name, ext) - filepath = os.path.join(staging_dir, filename) - filepath = filepath.replace("\\", "/") - default_option = f'CreatePreview filename:"{filepath}"' - job_args.append(default_option) - frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa - job_args.append(frame_option) - - for key, value in viewport_options.items(): - if isinstance(value, bool): - if value: - job_args.append(f"{key}:{value}") - - elif isinstance(value, str): - if key == "vpStyle": - if viewport_options[key] == "Realistic": - value = "defaultshading" - elif viewport_options[key] == "Shaded": - log.warning( - "'Shaded' Mode not supported in " - "preview animation in Max 2024..\n" - "Using 'defaultshading' instead") - value = "defaultshading" - elif viewport_options[key] == "ConsistentColors": - value = "flatcolor" - else: - value = value.lower() - elif key == "vpPreset": - if viewport_options[key] == "Quality": - value = "highquality" - elif viewport_options[key] == "Customize": - value = "userdefined" - else: - value = value.lower() - job_args.append(f"{key}: #{value}") - - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) - - job_str = " ".join(job_args) - log.debug(job_str) - - return job_str - - -def publish_preview_sequences(staging_dir, filename, - startFrame, endFrame, - percentSize, ext): - """publish preview animation by creating bitmaps - ***For 3dsMax Version <2024 - - Args: - staging_dir (str): staging directory - filename (str): filename - startFrame (int): start frame - endFrame (int): end frame - percentSize (int): percentage of the resolution - ext (str): image extension - """ - # get the screenshot - resolution_percentage = float(percentSize) / 100 - res_width = rt.renderWidth * resolution_percentage - res_height = rt.renderHeight * resolution_percentage - - viewportRatio = float(res_width / res_height) - - for i in range(startFrame, endFrame + 1): - rt.sliderTime = i - fname = "{}.{:04}.{}".format(filename, i, ext) - filepath = os.path.join(staging_dir, fname) - filepath = filepath.replace("\\", "/") - preview_res = rt.bitmap( - res_width, res_height, filename=filepath) - dib = rt.gw.getViewportDib() - dib_width = float(dib.width) - dib_height = float(dib.height) - renderRatio = float(dib_width / dib_height) - if viewportRatio <= renderRatio: - heightCrop = (dib_width / renderRatio) - topEdge = int((dib_height - heightCrop) / 2.0) - tempImage_bmp = rt.bitmap(dib_width, heightCrop) - src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) - else: - widthCrop = dib_height * renderRatio - leftEdge = int((dib_width - widthCrop) / 2.0) - tempImage_bmp = rt.bitmap(widthCrop, dib_height) - src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height) - rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) - - # copy the bitmap and close it - rt.copy(tempImage_bmp, preview_res) - rt.close(tempImage_bmp) - - rt.save(preview_res) - rt.close(preview_res) - - rt.close(dib) - - if rt.keyboard.escPressed: - rt.exit() - # clean up the cache - rt.gc(delayed=True) - - -def publish_preview_animation( - instance, staging_dir, - ext, review_camera, - startFrame=None, endFrame=None, - viewport_options=None): - """Render camera review animation - - Args: - instance (pyblish.api.instance): Instance - filepath (str): filepath - review_camera (str): viewport camera for preview render - startFrame (int): start frame - endFrame (int): end frame - viewport_options (dict): viewport setting options - """ - - if startFrame is None: - startFrame = int(rt.animationRange.start) - if endFrame is None: - endFrame = int(rt.animationRange.end) - if viewport_options is None: - viewport_options = viewport_options_for_preview_animation() - with play_preview_when_done(False): - with viewport_camera(review_camera): - if int(get_max_version()) < 2024: - with viewport_preference_setting( - viewport_options["general_viewport"], - viewport_options["nitrous_viewport"], - viewport_options["vp_btn_mgr"]): - percentSize = viewport_options.get("percentSize", 100) - - publish_preview_sequences( - staging_dir, instance.name, - startFrame, endFrame, percentSize, ext) - else: - fps = instance.data["fps"] - rt.completeRedraw() - preview_arg = publish_review_animation(instance, staging_dir, - startFrame, endFrame, - ext, fps, viewport_options) - rt.execute(preview_arg) - - rt.completeRedraw() - -def viewport_options_for_preview_animation(): - """ - Function to store the default data of viewport options - Returns: - dict: viewport setting options - - """ - # viewport_options should be the dictionary - if int(get_max_version()) < 2024: - return { - "visualStyleMode": "defaultshading", - "viewportPreset": "highquality", - "percentSize": 100, - "vpTexture": False, - "dspGeometry": True, - "dspShapes": False, - "dspLights": False, - "dspCameras": False, - "dspHelpers": False, - "dspParticles": True, - "dspBones": False, - "dspBkg": True, - "dspGrid": False, - "dspSafeFrame":False, - "dspFrameNums": False - } - else: - viewport_options = {} - viewport_options.update({"percentSize": 100}) - general_viewport = { - "dspBkg": True, - "dspGrid": False - } - nitrous_viewport = { - "VisualStyleMode": "defaultshading", - "ViewportPreset": "highquality", - "UseTextureEnabled": False - } - viewport_options["general_viewport"] = general_viewport - viewport_options["nitrous_viewport"] = nitrous_viewport - viewport_options["vp_btn_mgr"] = { - "EnableButtons": False} - return viewport_options From c93c26462d9bda7bc87937d109c535691b06da37 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 18:51:32 +0800 Subject: [PATCH 0695/1224] clean up the code for the tycache publishing --- .../hosts/max/plugins/load/load_tycache.py | 3 +- .../publish/collect_tycache_attributes.py | 2 +- .../max/plugins/publish/extract_tycache.py | 35 +++++++++---------- .../plugins/publish/validate_tyflow_data.py | 33 ++++++++--------- 4 files changed, 32 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index ff3a26fbd6..f878ed9f1c 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -46,8 +46,7 @@ class TyCacheLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) - update_custom_attribute_data( - node, node_list) + update_custom_attribute_data(node, node_list) with maintained_selection(): for prt in node_list: prt.filename = path diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py index 779b4c1b7e..0351ca45c5 100644 --- a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -6,7 +6,7 @@ from openpype.pipeline.publish import OpenPypePyblishPluginMixin class CollectTyCacheData(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): - """Collect Review Data for Preview Animation""" + """Collect Channel Attributes for TyCache Export""" order = pyblish.api.CollectorOrder + 0.02 label = "Collect tyCache attribute Data" diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 33dde03667..49721f47fe 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -115,26 +115,23 @@ class ExtractTyCache(publish.Extractor): list of arguments for MAX Script. """ - job_args = [] - opt_list = self.get_operators(members) - for operator in opt_list: - job_args.append(f"{operator}.exportMode=2") - start_frame = f"{operator}.frameStart={start}" - job_args.append(start_frame) - end_frame = f"{operator}.frameEnd={end}" - job_args.append(end_frame) - filepath = filepath.replace("\\", "/") - tycache_filename = f'{operator}.tyCacheFilename="{filepath}"' - job_args.append(tycache_filename) - # TODO: add the additional job args for tycache attributes - if additional_attributes: - additional_args = self.get_additional_attribute_args( - operator, additional_attributes - ) - job_args.extend(additional_args) - tycache_export = f"{operator}.exportTyCache()" - job_args.append(tycache_export) + settings = { + "exportMode": 2, + "frameStart": start, + "frameEnd": end, + "tyCacheFilename": filepath.replace("\\", "/") + } + settings.update(additional_attributes) + job_args = [] + for operator in self.get_operators(members): + for key, value in settings.items(): + if isinstance(value, str): + # embed in quotes + value = f'"{value}"' + + job_args.append(f"{operator}.{key}={value}") + job_args.append(f"{operator}.exportTyCache()") return job_args @staticmethod diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index 4b2bf975ee..67c35ec01c 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -52,12 +52,7 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): selection_list = instance.data["members"] for sel in selection_list: - sel_tmp = str(sel) - if rt.ClassOf(sel) in [rt.tyFlow, - rt.Editable_Mesh]: - if "tyFlow" not in sel_tmp: - invalid.append(sel) - else: + if rt.ClassOf(sel) not in [rt.tyFlow, rt.Editable_Mesh]: invalid.append(sel) return invalid @@ -75,22 +70,22 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): of the node connections """ invalid = [] - container = instance.data["instance_node"] - self.log.debug(f"Validating tyFlow object for {container}") - selection_list = instance.data["members"] - bool_list = [] - for sel in selection_list: - obj = sel.baseobject + members = instance.data["members"] + for member in members: + obj = member.baseobject + + # There must be at least one animation with export + # particles enabled + has_export_particles = False anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: - # get all the names of the related tyFlow nodes + # get name of the related tyFlow node sub_anim = rt.GetSubAnim(obj, anim_name) # check if there is export particle operator - boolean = rt.IsProperty(sub_anim, "Export_Particles") - bool_list.append(str(boolean)) - # if the export_particles property is not there - # it means there is not a "Export Particle" operator - if "True" not in bool_list: - invalid.append(sel) + if rt.IsProperty(sub_anim, "Export_Particles"): + has_export_particles = True + break + if not has_export_particles: + invalid.append(member) return invalid From 9868b09c9bbd546d98148c7a80c087b87f84a766 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 10 Oct 2023 15:58:34 +0200 Subject: [PATCH 0696/1224] prepared context dialog using AYON calls --- openpype/tools/context_dialog/_ayon_window.py | 783 ++++++++++++++++++ .../tools/context_dialog/_openpype_window.py | 396 +++++++++ openpype/tools/context_dialog/window.py | 402 +-------- 3 files changed, 1188 insertions(+), 393 deletions(-) create mode 100644 openpype/tools/context_dialog/_ayon_window.py create mode 100644 openpype/tools/context_dialog/_openpype_window.py diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py new file mode 100644 index 0000000000..6514780236 --- /dev/null +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -0,0 +1,783 @@ +import os +import json + +import ayon_api +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.lib.events import QueuedEventSystem +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.utils.lib import ( + center_window, + get_openpype_qt_app, +) + + +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_id = None + self._task_name = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class ContextDialogController: + def __init__(self): + self._event_system = None + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + + self._confirmed = False + self._is_strict = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + def reset(self): + self._emit_event("controller.reset.started") + + self._confirmed = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.reset.finished") + + def refresh(self): + self._emit_event("controller.refresh.started") + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.refresh.finished") + + # Event handling + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._get_event_system().emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._get_event_system().add_callback(topic, callback) + + def set_output_json_path(self, output_path): + self._output_path = output_path + + def is_strict(self): + return self._is_strict + + def set_strict(self, enabled): + if self._is_strict is enabled: + return + self._is_strict = enabled + self._emit_event("strict.changed", {"strict": enabled}) + + # Data model functions + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + # Expected selection helpers + def set_expected_selection(self, project_name, folder_id): + return self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Selection handling + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def is_initial_context_valid(self): + return self._initial_folder_found and self._initial_project_found + + def set_initial_context( + self, project_name=None, asset_name=None, folder_path=None + ): + if project_name is None: + project_found = True + asset_name = None + folder_path = None + + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + + folder_id = None + folder_found = True + folder_label = None + if folder_path: + folder_label = folder_path + folder = ayon_api.get_folder_by_path(project_name, folder_path) + if folder: + folder_id = folder["id"] + else: + folder_found = False + elif asset_name: + folder_label = asset_name + for folder in ayon_api.get_folders( + project_name, folder_names=[asset_name] + ): + folder_id = folder["id"] + break + if not folder_id: + folder_found = False + + tasks_found = True + if folder_found and (folder_path or asset_name): + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + if not tasks: + tasks_found = False + + self._initial_project_name = project_name + self._initial_folder_id = folder_id + self._initial_folder_label = folder_label + self._initial_folder_found = project_found + self._initial_folder_found = folder_found + self._initial_tasks_found = tasks_found + self._emit_event( + "initial.context.changed", + self.get_initial_context() + ) + + def get_initial_context(self): + return { + "project_name": self._initial_project_name, + "folder_id": self._initial_folder_id, + "folder_label": self._initial_folder_label, + "project_found": self._initial_project_found, + "folder_found": self._initial_folder_found, + "tasks_found": self._initial_tasks_found, + "valid": ( + self._initial_project_found + and self._initial_folder_found + and self._initial_tasks_found + ) + } + + # Result of this tool + def get_selected_context(self): + return { + "project": None, + "project_name": None, + "asset": None, + "folder_id": None, + "folder_path": None, + "task": None, + "task_id": None, + "task_name": None, + } + + def window_closed(self): + if not self._confirmed and not self._is_strict: + return + + self._store_output() + + def confirm_selection(self): + self._confirmed = True + self._emit_event( + "selection.confirmed", + {"confirmed": True} + ) + + def _store_output(self): + if not self._output_path: + return + + dirpath = os.path.dirname(self._output_path) + os.makedirs(dirpath, exist_ok=True) + with open(self._output_path, "w") as stream: + json.dump(self.get_selected_context(), stream) + + def _get_event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + +class InvalidContextOverlay(QtWidgets.QFrame): + confirmed = QtCore.Signal() + + def __init__(self, parent): + super(InvalidContextOverlay, self).__init__(parent) + self.setObjectName("OverlayFrame") + + mid_widget = QtWidgets.QWidget(self) + label_widget = QtWidgets.QLabel( + "Requested context was not found...", + mid_widget + ) + + confirm_btn = QtWidgets.QPushButton("Close", mid_widget) + + mid_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + mid_layout = QtWidgets.QVBoxLayout(mid_widget) + mid_layout.setContentsMargins(0, 0, 0, 0) + mid_layout.addWidget(label_widget, 0) + mid_layout.addSpacing(30) + mid_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(mid_widget, 1, 1) + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 0) + main_layout.setRowStretch(2, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 0) + main_layout.setColumnStretch(2, 1) + + confirm_btn.clicked.connect(self.confirmed) + + self._label_widget = label_widget + self._confirm_btn = confirm_btn + + def set_context( + self, + project_name, + folder_label, + project_found, + folder_found, + tasks_found, + ): + lines = [] + if not project_found: + lines.extend([ + "Requested project {} was not found...".format(project_name), + ]) + + elif not folder_found: + lines.extend([ + "Requested folder was not found...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + elif not tasks_found: + lines.extend([ + "Requested folder does not have any tasks...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + else: + lines.append("Requested context was not found...") + self._label_widget.setText("
".join(lines)) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, controller=None, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + if controller is None: + controller = ContextDialogController() + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = ProjectsCombobox( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + # Assets widget + folders_widget = FoldersWidget( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox, 0) + left_side_layout.addWidget(folders_widget, 1) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(controller, parent=main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + overlay_widget = InvalidContextOverlay(self) + overlay_widget.setVisible(False) + + ok_btn.clicked.connect(self._on_ok_click) + project_combobox.refreshed.connect(self._on_projects_refresh) + overlay_widget.confirmed.connect(self._on_overlay_confirm) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_selection_change + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_selection_change + ) + controller.register_event_callback( + "initial.context.changed", + self._on_init_context_change + ) + controller.register_event_callback( + "strict.changed", + self._on_strict_changed + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + + # Set stylehseet and resize window on first show + self._first_show = True + self._visible = False + + self._controller = controller + + self._project_combobox = project_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._overlay_widget = overlay_widget + + self._apply_strict_changes(self.is_strict()) + + def is_strict(self): + return self._controller.is_strict() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + self._visible = True + + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + self._controller.refresh() + + initial_context = self._controller.get_initial_context() + self._set_init_context(initial_context) + self._overlay_widget.resize(self.size()) + + def resizeEvent(self, event): + super(ContextDialog, self).resizeEvent(event) + self._overlay_widget.resize(self.size()) + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self.is_strict() and not self._ok_btn.isEnabled(): + # Allow to close window when initial context is not valid + if self._controller.is_initial_context_valid(): + event.ignore() + return + + if self.is_strict(): + self._controller.confirm_selection() + self._visible = False + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, enabled): + """Change strictness of dialog.""" + + self._controller.set_strict(enabled) + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + + self._controller.reset() + + def get_context(self): + """Result of dialog.""" + return self._controller.get_selected_context() + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + + self._controller.set_initial_context(project_name, asset_name) + + def _on_projects_refresh(self): + initial_context = self._controller.get_initial_context() + self._controller.set_expected_selection( + initial_context["project_name"], + initial_context["folder_id"] + ) + + def _on_overlay_confirm(self): + self.close() + + def _on_ok_click(self): + # Store values to output + self._controller.confirm_selection() + # Close dialog + self.accept() + + def _on_project_selection_change(self, event): + self._on_selection_change( + event["project_name"], + ) + + def _on_folder_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + ) + + def _on_task_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + event["task_name"], + ) + + def _on_selection_change( + self, project_name, folder_id=None, task_name=None + ): + self._validate_strict(project_name, folder_id, task_name) + + def _on_init_context_change(self, event): + self._set_init_context(event.data) + if self._visible: + self._controller.set_expected_selection( + event["project_name"], event["folder_id"] + ) + + def _set_init_context(self, init_context): + project_name = init_context["project_name"] + if not init_context["valid"]: + self._overlay_widget.setVisible(True) + self._overlay_widget.set_context( + project_name, + init_context["folder_label"], + init_context["project_found"], + init_context["folder_found"], + init_context["tasks_found"] + ) + return + + self._overlay_widget.setVisible(False) + if project_name: + self._project_combobox.setEnabled(False) + if init_context["folder_id"]: + self._folders_widget.setEnabled(False) + else: + self._project_combobox.setEnabled(True) + self._folders_widget.setEnabled(True) + + def _on_strict_changed(self, event): + self._apply_strict_changes(event["strict"]) + + def _on_controller_reset(self): + self._apply_strict_changes(self.is_strict()) + self._project_combobox.refresh() + + def _on_controller_refresh(self): + self._project_combobox.refresh() + + def _apply_strict_changes(self, is_strict): + if not is_strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + context = self._controller.get_selected_context() + self._validate_strict( + context["project_name"], + context["folder_id"], + context["task_name"] + ) + + def _validate_strict(self, project_name, folder_id, task_name): + if not self.is_strict(): + return + + enabled = True + if not project_name or not folder_id or not task_name: + enabled = False + self._ok_btn.setEnabled(enabled) + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + controller = ContextDialogController() + controller.set_strict(strict) + controller.set_initial_context(project_name, asset_name) + controller.set_output_json_path(path_to_store) + window = ContextDialog(controller=controller) + window.show() + app.exec_() + + # Get result from window + data = window.get_context() + + # Make sure json filepath directory exists + file_dir = os.path.dirname(path_to_store) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # Store result into json file + with open(path_to_store, "w") as stream: + json.dump(data, stream) diff --git a/openpype/tools/context_dialog/_openpype_window.py b/openpype/tools/context_dialog/_openpype_window.py new file mode 100644 index 0000000000..d370772a7f --- /dev/null +++ b/openpype/tools/context_dialog/_openpype_window.py @@ -0,0 +1,396 @@ +import os +import json + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.pipeline import AvalonMongoDB +from openpype.tools.utils.lib import center_window, get_openpype_qt_app +from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget +from openpype.tools.utils.constants import ( + PROJECT_NAME_ROLE +) +from openpype.tools.utils.tasks_widget import TasksWidget +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + dbcon = AvalonMongoDB() + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(left_side_widget) + # Styled delegate to propagate stylessheet + project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) + project_combobox.setItemDelegate(project_delegate) + # Project model with only active projects without default item + project_model = ProjectModel( + dbcon, + only_active=True, + add_default_project=False + ) + # Sorting proxy model + project_proxy = ProjectSortFilterProxy() + project_proxy.setSourceModel(project_model) + project_combobox.setModel(project_proxy) + + # Assets widget + assets_widget = SingleSelectAssetsWidget( + dbcon, parent=left_side_widget + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox) + left_side_layout.addWidget(assets_widget) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(dbcon, main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + # Timer which will trigger asset refresh + # - this is needed because asset widget triggers + # finished refresh before hides spin box so we need to trigger + # refreshing in small offset if we want re-refresh asset widget + assets_timer = QtCore.QTimer() + assets_timer.setInterval(50) + assets_timer.setSingleShot(True) + + assets_timer.timeout.connect(self._on_asset_refresh_timer) + + project_combobox.currentIndexChanged.connect( + self._on_project_combo_change + ) + assets_widget.selection_changed.connect(self._on_asset_change) + assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) + assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) + tasks_widget.task_changed.connect(self._on_task_change) + ok_btn.clicked.connect(self._on_ok_click) + + self._dbcon = dbcon + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._assets_widget = assets_widget + + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._strict = False + + # Values set by `set_context` method + self._set_context_project = None + self._set_context_asset = None + + # Requirements for asset widget refresh + self._assets_timer = assets_timer + self._rerefresh_assets = True + self._assets_refreshing = False + + # Set stylehseet and resize window on first show + self._first_show = True + + # Helper attributes for handling of refresh + self._ignore_value_changes = False + self._refresh_on_next_show = True + + # Output of dialog + self._context_to_store = { + "project": None, + "asset": None, + "task": None + } + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self._strict and not self._ok_btn.isEnabled(): + event.ignore() + return + + if self._strict: + self._confirm_values() + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, strict): + """Change strictness of dialog.""" + self._strict = strict + self._validate_strict() + + def _set_refresh_on_next_show(self): + """Refresh will be called on next showEvent. + + If window is already visible then just execute refresh. + """ + self._refresh_on_next_show = True + if self.isVisible(): + self.refresh() + + def _refresh_assets(self): + """Trigger refreshing of asset widget. + + This will set mart to rerefresh asset when current refreshing is done + or do it immidietely if asset widget is not refreshing at the time. + """ + if self._assets_refreshing: + self._rerefresh_assets = True + else: + self._on_asset_refresh_timer() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + + if self._refresh_on_next_show: + self.refresh() + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + # Change state of refreshing (no matter how refresh was called) + self._refresh_on_next_show = False + + # Ignore changes of combobox and asset widget + self._ignore_value_changes = True + + # Get current project name to be able set it afterwards + select_project_name = self._dbcon.Session.get("AVALON_PROJECT") + # Trigger project refresh + self._project_model.refresh() + # Sort projects + self._project_proxy.sort(0) + + # Disable combobox if project was passed to `set_context` + if self._set_context_project: + select_project_name = self._set_context_project + self._project_combobox.setEnabled(False) + else: + # Find new project to select + self._project_combobox.setEnabled(True) + if ( + select_project_name is None + and self._project_proxy.rowCount() > 0 + ): + index = self._project_proxy.index(0, 0) + select_project_name = index.data(PROJECT_NAME_ROLE) + + self._ignore_value_changes = False + + idx = self._project_combobox.findText(select_project_name) + if idx >= 0: + self._project_combobox.setCurrentIndex(idx) + self._dbcon.Session["AVALON_PROJECT"] = ( + self._project_combobox.currentText() + ) + + # Trigger asset refresh + self._refresh_assets() + + def _on_asset_refresh_timer(self): + """This is only way how to trigger refresh asset widget. + + Use `_refresh_assets` method to refresh asset widget. + """ + self._assets_widget.refresh() + + def _on_asset_widget_refresh_finished(self): + """Catch when asset widget finished refreshing.""" + # If should refresh again then skip all other callbacks and trigger + # assets timer directly. + self._assets_refreshing = False + if self._rerefresh_assets: + self._rerefresh_assets = False + self._assets_timer.start() + return + + self._ignore_value_changes = True + if self._set_context_asset: + self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset + self._assets_widget.setEnabled(False) + self._assets_widget.select_asset_by_name(self._set_context_asset) + self._set_asset_to_tasks_widget() + else: + self._assets_widget.setEnabled(True) + self._assets_widget.set_current_asset_btn_visibility(False) + + # Refresh tasks + self._tasks_widget.refresh() + + self._ignore_value_changes = False + + self._validate_strict() + + def _on_project_combo_change(self): + if self._ignore_value_changes: + return + project_name = self._project_combobox.currentText() + + if self._dbcon.Session.get("AVALON_PROJECT") == project_name: + return + + self._dbcon.Session["AVALON_PROJECT"] = project_name + + self._refresh_assets() + self._validate_strict() + + def _on_asset_refresh_trigger(self): + self._assets_refreshing = True + self._on_asset_change() + + def _on_asset_change(self): + """Selected assets have changed""" + if self._ignore_value_changes: + return + self._set_asset_to_tasks_widget() + + def _on_task_change(self): + self._validate_strict() + + def _set_asset_to_tasks_widget(self): + asset_id = self._assets_widget.get_selected_asset_id() + + self._tasks_widget.set_asset_id(asset_id) + + def _confirm_values(self): + """Store values to output.""" + self._context_to_store["project"] = self.get_selected_project() + self._context_to_store["asset"] = self.get_selected_asset() + self._context_to_store["task"] = self.get_selected_task() + + def _on_ok_click(self): + # Store values to output + self._confirm_values() + # Close dialog + self.accept() + + def get_selected_project(self): + """Get selected project.""" + return self._project_combobox.currentText() + + def get_selected_asset(self): + """Currently selected asset in asset widget.""" + return self._assets_widget.get_selected_asset_name() + + def get_selected_task(self): + """Currently selected task.""" + return self._tasks_widget.get_selected_task_name() + + def _validate_strict(self): + if not self._strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + + enabled = True + if not self._set_context_project and not self.get_selected_project(): + enabled = False + elif not self._set_context_asset and not self.get_selected_asset(): + enabled = False + elif not self.get_selected_task(): + enabled = False + self._ok_btn.setEnabled(enabled) + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + if project_name is None: + asset_name = None + + self._set_context_project = project_name + self._set_context_asset = asset_name + + self._context_to_store["project"] = project_name + self._context_to_store["asset"] = asset_name + + self._set_refresh_on_next_show() + + def get_context(self): + """Result of dialog.""" + return self._context_to_store + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + window = ContextDialog() + window.set_strict(strict) + window.set_context(project_name, asset_name) + window.show() + app.exec_() + + # Get result from window + data = window.get_context() + + # Make sure json filepath directory exists + file_dir = os.path.dirname(path_to_store) + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + # Store result into json file + with open(path_to_store, "w") as stream: + json.dump(data, stream) diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py index 4fe41c9949..15b90463da 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/window.py @@ -1,396 +1,12 @@ -import os -import json +from openpype import AYON_SERVER_ENABLED -from qtpy import QtWidgets, QtCore, QtGui +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main -from openpype import style -from openpype.pipeline import AvalonMongoDB -from openpype.tools.utils.lib import center_window, get_openpype_qt_app -from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget -from openpype.tools.utils.constants import ( - PROJECT_NAME_ROLE + +__all__ = ( + "ContextDialog", + "main", ) -from openpype.tools.utils.tasks_widget import TasksWidget -from openpype.tools.utils.models import ( - ProjectModel, - ProjectSortFilterProxy -) - - -class ContextDialog(QtWidgets.QDialog): - """Dialog to select a context. - - Context has 3 parts: - - Project - - Aseet - - Task - - It is possible to predefine project and asset. In that case their widgets - will have passed preselected values and will be disabled. - """ - def __init__(self, parent=None): - super(ContextDialog, self).__init__(parent) - - self.setWindowTitle("Select Context") - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) - - # Enable minimize and maximize for app - window_flags = QtCore.Qt.Window - if not parent: - window_flags |= QtCore.Qt.WindowStaysOnTopHint - self.setWindowFlags(window_flags) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - dbcon = AvalonMongoDB() - - # UI initialization - main_splitter = QtWidgets.QSplitter(self) - - # Left side widget contains project combobox and asset widget - left_side_widget = QtWidgets.QWidget(main_splitter) - - project_combobox = QtWidgets.QComboBox(left_side_widget) - # Styled delegate to propagate stylessheet - project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) - project_combobox.setItemDelegate(project_delegate) - # Project model with only active projects without default item - project_model = ProjectModel( - dbcon, - only_active=True, - add_default_project=False - ) - # Sorting proxy model - project_proxy = ProjectSortFilterProxy() - project_proxy.setSourceModel(project_model) - project_combobox.setModel(project_proxy) - - # Assets widget - assets_widget = SingleSelectAssetsWidget( - dbcon, parent=left_side_widget - ) - - left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) - left_side_layout.setContentsMargins(0, 0, 0, 0) - left_side_layout.addWidget(project_combobox) - left_side_layout.addWidget(assets_widget) - - # Right side of window contains only tasks - tasks_widget = TasksWidget(dbcon, main_splitter) - - # Add widgets to main splitter - main_splitter.addWidget(left_side_widget) - main_splitter.addWidget(tasks_widget) - - # Set stretch of both sides - main_splitter.setStretchFactor(0, 7) - main_splitter.setStretchFactor(1, 3) - - # Add confimation button to bottom right - ok_btn = QtWidgets.QPushButton("OK", self) - - buttons_layout = QtWidgets.QHBoxLayout() - buttons_layout.setContentsMargins(0, 0, 0, 0) - buttons_layout.addStretch(1) - buttons_layout.addWidget(ok_btn, 0) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(main_splitter, 1) - main_layout.addLayout(buttons_layout, 0) - - # Timer which will trigger asset refresh - # - this is needed because asset widget triggers - # finished refresh before hides spin box so we need to trigger - # refreshing in small offset if we want re-refresh asset widget - assets_timer = QtCore.QTimer() - assets_timer.setInterval(50) - assets_timer.setSingleShot(True) - - assets_timer.timeout.connect(self._on_asset_refresh_timer) - - project_combobox.currentIndexChanged.connect( - self._on_project_combo_change - ) - assets_widget.selection_changed.connect(self._on_asset_change) - assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) - assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) - tasks_widget.task_changed.connect(self._on_task_change) - ok_btn.clicked.connect(self._on_ok_click) - - self._dbcon = dbcon - - self._project_combobox = project_combobox - self._project_model = project_model - self._project_proxy = project_proxy - self._project_delegate = project_delegate - - self._assets_widget = assets_widget - - self._tasks_widget = tasks_widget - - self._ok_btn = ok_btn - - self._strict = False - - # Values set by `set_context` method - self._set_context_project = None - self._set_context_asset = None - - # Requirements for asset widget refresh - self._assets_timer = assets_timer - self._rerefresh_assets = True - self._assets_refreshing = False - - # Set stylehseet and resize window on first show - self._first_show = True - - # Helper attributes for handling of refresh - self._ignore_value_changes = False - self._refresh_on_next_show = True - - # Output of dialog - self._context_to_store = { - "project": None, - "asset": None, - "task": None - } - - def closeEvent(self, event): - """Ignore close event if is in strict state and context is not done.""" - if self._strict and not self._ok_btn.isEnabled(): - event.ignore() - return - - if self._strict: - self._confirm_values() - super(ContextDialog, self).closeEvent(event) - - def set_strict(self, strict): - """Change strictness of dialog.""" - self._strict = strict - self._validate_strict() - - def _set_refresh_on_next_show(self): - """Refresh will be called on next showEvent. - - If window is already visible then just execute refresh. - """ - self._refresh_on_next_show = True - if self.isVisible(): - self.refresh() - - def _refresh_assets(self): - """Trigger refreshing of asset widget. - - This will set mart to rerefresh asset when current refreshing is done - or do it immidietely if asset widget is not refreshing at the time. - """ - if self._assets_refreshing: - self._rerefresh_assets = True - else: - self._on_asset_refresh_timer() - - def showEvent(self, event): - """Override show event to do some callbacks.""" - super(ContextDialog, self).showEvent(event) - if self._first_show: - self._first_show = False - # Set stylesheet and resize - self.setStyleSheet(style.load_stylesheet()) - self.resize(600, 700) - center_window(self) - - if self._refresh_on_next_show: - self.refresh() - - def refresh(self): - """Refresh all widget one by one. - - When asset refresh is triggered we have to wait when is done so - this method continues with `_on_asset_widget_refresh_finished`. - """ - # Change state of refreshing (no matter how refresh was called) - self._refresh_on_next_show = False - - # Ignore changes of combobox and asset widget - self._ignore_value_changes = True - - # Get current project name to be able set it afterwards - select_project_name = self._dbcon.Session.get("AVALON_PROJECT") - # Trigger project refresh - self._project_model.refresh() - # Sort projects - self._project_proxy.sort(0) - - # Disable combobox if project was passed to `set_context` - if self._set_context_project: - select_project_name = self._set_context_project - self._project_combobox.setEnabled(False) - else: - # Find new project to select - self._project_combobox.setEnabled(True) - if ( - select_project_name is None - and self._project_proxy.rowCount() > 0 - ): - index = self._project_proxy.index(0, 0) - select_project_name = index.data(PROJECT_NAME_ROLE) - - self._ignore_value_changes = False - - idx = self._project_combobox.findText(select_project_name) - if idx >= 0: - self._project_combobox.setCurrentIndex(idx) - self._dbcon.Session["AVALON_PROJECT"] = ( - self._project_combobox.currentText() - ) - - # Trigger asset refresh - self._refresh_assets() - - def _on_asset_refresh_timer(self): - """This is only way how to trigger refresh asset widget. - - Use `_refresh_assets` method to refresh asset widget. - """ - self._assets_widget.refresh() - - def _on_asset_widget_refresh_finished(self): - """Catch when asset widget finished refreshing.""" - # If should refresh again then skip all other callbacks and trigger - # assets timer directly. - self._assets_refreshing = False - if self._rerefresh_assets: - self._rerefresh_assets = False - self._assets_timer.start() - return - - self._ignore_value_changes = True - if self._set_context_asset: - self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset - self._assets_widget.setEnabled(False) - self._assets_widget.select_assets(self._set_context_asset) - self._set_asset_to_tasks_widget() - else: - self._assets_widget.setEnabled(True) - self._assets_widget.set_current_asset_btn_visibility(False) - - # Refresh tasks - self._tasks_widget.refresh() - - self._ignore_value_changes = False - - self._validate_strict() - - def _on_project_combo_change(self): - if self._ignore_value_changes: - return - project_name = self._project_combobox.currentText() - - if self._dbcon.Session.get("AVALON_PROJECT") == project_name: - return - - self._dbcon.Session["AVALON_PROJECT"] = project_name - - self._refresh_assets() - self._validate_strict() - - def _on_asset_refresh_trigger(self): - self._assets_refreshing = True - self._on_asset_change() - - def _on_asset_change(self): - """Selected assets have changed""" - if self._ignore_value_changes: - return - self._set_asset_to_tasks_widget() - - def _on_task_change(self): - self._validate_strict() - - def _set_asset_to_tasks_widget(self): - asset_id = self._assets_widget.get_selected_asset_id() - - self._tasks_widget.set_asset_id(asset_id) - - def _confirm_values(self): - """Store values to output.""" - self._context_to_store["project"] = self.get_selected_project() - self._context_to_store["asset"] = self.get_selected_asset() - self._context_to_store["task"] = self.get_selected_task() - - def _on_ok_click(self): - # Store values to output - self._confirm_values() - # Close dialog - self.accept() - - def get_selected_project(self): - """Get selected project.""" - return self._project_combobox.currentText() - - def get_selected_asset(self): - """Currently selected asset in asset widget.""" - return self._assets_widget.get_selected_asset_name() - - def get_selected_task(self): - """Currently selected task.""" - return self._tasks_widget.get_selected_task_name() - - def _validate_strict(self): - if not self._strict: - if not self._ok_btn.isEnabled(): - self._ok_btn.setEnabled(True) - return - - enabled = True - if not self._set_context_project and not self.get_selected_project(): - enabled = False - elif not self._set_context_asset and not self.get_selected_asset(): - enabled = False - elif not self.get_selected_task(): - enabled = False - self._ok_btn.setEnabled(enabled) - - def set_context(self, project_name=None, asset_name=None): - """Set context which will be used and locked in dialog.""" - if project_name is None: - asset_name = None - - self._set_context_project = project_name - self._set_context_asset = asset_name - - self._context_to_store["project"] = project_name - self._context_to_store["asset"] = asset_name - - self._set_refresh_on_next_show() - - def get_context(self): - """Result of dialog.""" - return self._context_to_store - - -def main( - path_to_store, - project_name=None, - asset_name=None, - strict=True -): - # Run Qt application - app = get_openpype_qt_app() - window = ContextDialog() - window.set_strict(strict) - window.set_context(project_name, asset_name) - window.show() - app.exec_() - - # Get result from window - data = window.get_context() - - # Make sure json filepath directory exists - file_dir = os.path.dirname(path_to_store) - if not os.path.exists(file_dir): - os.makedirs(file_dir) - - # Store result into json file - with open(path_to_store, "w") as stream: - json.dump(data, stream) From b374bf7eaebcb151a38447593ab54864e4cc65ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:47:21 +0200 Subject: [PATCH 0697/1224] fix storing of data --- openpype/tools/context_dialog/_ayon_window.py | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 6514780236..04fd3495e1 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -356,27 +356,17 @@ class ContextDialogController: "task_name": None, } - def window_closed(self): - if not self._confirmed and not self._is_strict: - return - - self._store_output() - def confirm_selection(self): self._confirmed = True - self._emit_event( - "selection.confirmed", - {"confirmed": True} - ) - def _store_output(self): + def store_output(self): if not self._output_path: return dirpath = os.path.dirname(self._output_path) os.makedirs(dirpath, exist_ok=True) with open(self._output_path, "w") as stream: - json.dump(self.get_selected_context(), stream) + json.dump(self.get_selected_context(), stream, indent=4) def _get_event_system(self): """Inner event system for workfiles tool controller. @@ -627,7 +617,7 @@ class ContextDialog(QtWidgets.QDialog): return if self.is_strict(): - self._controller.confirm_selection() + self._confirm_selection() self._visible = False super(ContextDialog, self).closeEvent(event) @@ -666,10 +656,13 @@ class ContextDialog(QtWidgets.QDialog): def _on_ok_click(self): # Store values to output - self._controller.confirm_selection() + self._confirm_selection() # Close dialog self.accept() + def _confirm_selection(self): + self._controller.confirm_selection() + def _on_project_selection_change(self, event): self._on_selection_change( event["project_name"], @@ -769,15 +762,4 @@ def main( window = ContextDialog(controller=controller) window.show() app.exec_() - - # Get result from window - data = window.get_context() - - # Make sure json filepath directory exists - file_dir = os.path.dirname(path_to_store) - if not os.path.exists(file_dir): - os.makedirs(file_dir) - - # Store result into json file - with open(path_to_store, "w") as stream: - json.dump(data, stream) + controller.store_output() From f86874df3de22841587697cc8246eda14ddb4978 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:47:46 +0200 Subject: [PATCH 0698/1224] add select item to projects combobox --- openpype/tools/context_dialog/_ayon_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 04fd3495e1..07495b7674 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -496,6 +496,7 @@ class ContextDialog(QtWidgets.QDialog): parent=left_side_widget, handle_expected_selection=True ) + project_combobox.set_select_item_visible(True) # Assets widget folders_widget = FoldersWidget( From 28bcbc8053133a1a257078052e9c2115167fea0d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:48:07 +0200 Subject: [PATCH 0699/1224] implemented helper function to prepare initial context data --- openpype/tools/context_dialog/_ayon_window.py | 136 +++++++++++------- 1 file changed, 84 insertions(+), 52 deletions(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 07495b7674..73f9ed139c 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -277,52 +277,15 @@ class ContextDialogController: def is_initial_context_valid(self): return self._initial_folder_found and self._initial_project_found - def set_initial_context( - self, project_name=None, asset_name=None, folder_path=None - ): - if project_name is None: - project_found = True - asset_name = None - folder_path = None - - else: - project = ayon_api.get_project(project_name) - project_found = project is not None - - folder_id = None - folder_found = True - folder_label = None - if folder_path: - folder_label = folder_path - folder = ayon_api.get_folder_by_path(project_name, folder_path) - if folder: - folder_id = folder["id"] - else: - folder_found = False - elif asset_name: - folder_label = asset_name - for folder in ayon_api.get_folders( - project_name, folder_names=[asset_name] - ): - folder_id = folder["id"] - break - if not folder_id: - folder_found = False - - tasks_found = True - if folder_found and (folder_path or asset_name): - tasks = list(ayon_api.get_tasks( - project_name, folder_ids=[folder_id], fields=["id"] - )) - if not tasks: - tasks_found = False + def set_initial_context(self, project_name=None, asset_name=None): + result = self._prepare_initial_context(project_name, asset_name) self._initial_project_name = project_name - self._initial_folder_id = folder_id - self._initial_folder_label = folder_label - self._initial_folder_found = project_found - self._initial_folder_found = folder_found - self._initial_tasks_found = tasks_found + self._initial_folder_id = result["folder_id"] + self._initial_folder_label = result["folder_label"] + self._initial_project_found = result["project_found"] + self._initial_folder_found = result["folder_found"] + self._initial_tasks_found = result["tasks_found"] self._emit_event( "initial.context.changed", self.get_initial_context() @@ -345,15 +308,36 @@ class ContextDialogController: # Result of this tool def get_selected_context(self): + project_name = None + folder_id = None + task_id = None + task_name = None + folder_path = None + folder_name = None + if self._confirmed: + project_name = self.get_selected_project_name() + folder_id = self.get_selected_folder_id() + task_id = self.get_selected_task_id() + task_name = self.get_selected_task_name() + + folder_item = None + if folder_id: + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + + if folder_item: + folder_path = folder_item.path + folder_name = folder_item.name return { - "project": None, - "project_name": None, - "asset": None, - "folder_id": None, - "folder_path": None, - "task": None, - "task_id": None, - "task_name": None, + "project": project_name, + "project_name": project_name, + "asset": folder_name, + "folder_id": folder_id, + "folder_path": folder_path, + "task": task_name, + "task_name": task_name, + "task_id": task_id, + "initial_context_valid": self.is_initial_context_valid(), } def confirm_selection(self): @@ -368,6 +352,54 @@ class ContextDialogController: with open(self._output_path, "w") as stream: json.dump(self.get_selected_context(), stream, indent=4) + def _prepare_initial_context(self, project_name, asset_name): + project_found = True + output = { + "project_found": project_found, + "folder_id": None, + "folder_label": None, + "folder_found": True, + "tasks_found": True, + } + if project_name is None: + asset_name = None + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + output["project_found"] = project_found + if not project_found or not asset_name: + return output + + output["folder_label"] = asset_name + + folder_id = None + folder_found = False + # First try to find by path + folder = ayon_api.get_folder_by_path(project_name, asset_name) + # Try to find by name if folder was not found by path + # - prevent to query by name if 'asset_name' contains '/' + if not folder and "/" not in asset_name: + folder = next( + ayon_api.get_folders( + project_name, folder_names=[asset_name], fields=["id"]), + None + ) + + if folder: + folder_id = folder["id"] + folder_found = True + + output["folder_id"] = folder_id + output["folder_found"] = folder_found + if not folder_found: + return output + + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + output["tasks_found"] = bool(tasks) + return output + def _get_event_system(self): """Inner event system for workfiles tool controller. From a62718dc72b62a1823513795695a55b0601bb5a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:48:24 +0200 Subject: [PATCH 0700/1224] project name is in quotes --- openpype/tools/context_dialog/_ayon_window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py index 73f9ed139c..f347978392 100644 --- a/openpype/tools/context_dialog/_ayon_window.py +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -468,7 +468,8 @@ class InvalidContextOverlay(QtWidgets.QFrame): lines = [] if not project_found: lines.extend([ - "Requested project {} was not found...".format(project_name), + "Requested project '{}' was not found...".format( + project_name), ]) elif not folder_found: From 2f15dca3f50bbf687efd86bfb8339c284fd6aa7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 14:51:59 +0200 Subject: [PATCH 0701/1224] removed 'window.py' --- openpype/tools/context_dialog/__init__.py | 12 +++++++----- openpype/tools/context_dialog/window.py | 12 ------------ 2 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 openpype/tools/context_dialog/window.py diff --git a/openpype/tools/context_dialog/__init__.py b/openpype/tools/context_dialog/__init__.py index 9b10baf903..15b90463da 100644 --- a/openpype/tools/context_dialog/__init__.py +++ b/openpype/tools/context_dialog/__init__.py @@ -1,10 +1,12 @@ -from .window import ( - ContextDialog, - main -) +from openpype import AYON_SERVER_ENABLED + +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main __all__ = ( "ContextDialog", - "main" + "main", ) diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py deleted file mode 100644 index 15b90463da..0000000000 --- a/openpype/tools/context_dialog/window.py +++ /dev/null @@ -1,12 +0,0 @@ -from openpype import AYON_SERVER_ENABLED - -if AYON_SERVER_ENABLED: - from ._ayon_window import ContextDialog, main -else: - from ._openpype_window import ContextDialog, main - - -__all__ = ( - "ContextDialog", - "main", -) From 15532b06c5277b78c54ace0829445d08c3050987 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 19:06:01 +0800 Subject: [PATCH 0702/1224] remove the unused function --- .../max/plugins/publish/extract_tycache.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 49721f47fe..03a6b55f93 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -159,25 +159,3 @@ class ExtractTyCache(publish.Extractor): opt_list.append(opt) return opt_list - - def get_additional_attribute_args(self, operator, attrs): - """Get Additional args with the attributes pre-set by user - - Args: - operator (str): export particle operator - attrs (dict): a dict which stores the additional attributes - added by user - - Returns: - additional_args(list): a list of additional args for MAX script - """ - additional_args = [] - for key, value in attrs.items(): - tyc_attribute = None - if isinstance(value, bool): - tyc_attribute = f"{operator}.{key}=True" - elif isinstance(value, str): - tyc_attribute = f'{operator}.{key}="{value}"' - additional_args.append(tyc_attribute) - self.log.debug(additional_args) - return additional_args From eb28f19fcfd7380ed9cc7b855263ca03ea53ade5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 19:17:29 +0800 Subject: [PATCH 0703/1224] change info to debug --- openpype/hosts/max/plugins/publish/extract_tycache.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index 03a6b55f93..d9d7c17cff 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -32,7 +32,7 @@ class ExtractTyCache(publish.Extractor): # TODO: let user decide the param start = int(instance.context.data["frameStart"]) end = int(instance.context.data.get("frameEnd")) - self.log.info("Extracting Tycache...") + self.log.debug("Extracting Tycache...") stagingdir = self.staging_dir(instance) filename = "{name}.tyc".format(**instance.data) @@ -55,7 +55,6 @@ class ExtractTyCache(publish.Extractor): "stagingDir": stagingdir, } representations.append(representation) - self.log.info(f"Extracted instance '{instance.name}' to: {filenames}") # Get the tyMesh filename for extraction mesh_filename = f"{instance.name}__tyMesh.tyc" @@ -67,8 +66,7 @@ class ExtractTyCache(publish.Extractor): "outputName": '__tyMesh' } representations.append(mesh_repres) - self.log.info( - f"Extracted instance '{instance.name}' to: {mesh_filename}") + self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}") def get_files(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. From f695ea72848d2df538ccbdcc14a3db482dd711f8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 19:24:13 +0800 Subject: [PATCH 0704/1224] cleanup the code --- openpype/hosts/max/api/preview_animation.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index bb2f1410d4..c6dd8737a7 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -33,9 +33,6 @@ def viewport_camera(camera): """ original = rt.viewport.getCamera() has_autoplay = rt.preferences.playPreviewWhenDone - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - orig_preset = viewport_setting.ViewportPreset if not original: # if there is no original camera # use the current camera as original @@ -48,7 +45,6 @@ def viewport_camera(camera): finally: rt.viewport.setCamera(original) rt.preferences.playPreviewWhenDone = has_autoplay - viewport_setting.ViewportPreset = orig_preset @contextlib.contextmanager @@ -95,6 +91,7 @@ def viewport_preference_setting(general_viewport, for key, value in nitrous_viewport_original.items(): setattr(viewport_setting, key, value) + def publish_review_animation(instance, staging_dir, start, end, ext, fps, viewport_options): """Function to set up preview arguments in MaxScript. From 100ba33cb296dd1265ad461e3dea85c7f97fe3a2 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 14:34:06 +0300 Subject: [PATCH 0705/1224] update doc string --- openpype/hosts/houdini/api/lib.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ed06ec539e..eab77ca19a 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -569,13 +569,23 @@ def get_template_from_value(key, value): def get_frame_data(node, handle_start=0, handle_end=0, log=None): - """Get the frame data: start frame, end frame and steps. + """Get the frame data: start frame, end frame, steps, + start frame with start handle and end frame with end handle. + + This function uses Houdini node as the source of truth + therefore users are allowed to publish their desired frame range. + + It also calculates frame start and end with handles. Args: node(hou.Node) + handle_start(int) + handle_end(int) + log(logging.Logger) Returns: - dict: frame data for start, end and steps. + dict: frame data for start, end, steps, + start with handle and end with handle """ From a37c7539bb6477ce26f7f5c816230a4783e2ccf0 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 12:52:48 +0100 Subject: [PATCH 0706/1224] Changed empty type to Single Arrow --- openpype/hosts/blender/plugins/load/load_abc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 73f08fcc98..8d1863d4d5 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -148,6 +148,7 @@ class CacheModelLoader(plugin.AssetLoader): bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) From 35c2a8328860c671d76e3f77279c5340da7c4e2d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 19 Oct 2023 14:07:49 +0200 Subject: [PATCH 0707/1224] store version contexts by version id --- openpype/tools/ayon_loader/models/actions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py index 3edb04e9eb..177335a933 100644 --- a/openpype/tools/ayon_loader/models/actions.py +++ b/openpype/tools/ayon_loader/models/actions.py @@ -447,11 +447,12 @@ class LoaderActionsModel: project_doc["code"] = project_doc["data"]["code"] for version_doc in version_docs: + version_id = version_doc["_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] - version_context_by_id[product_id] = { + version_context_by_id[version_id] = { "project": project_doc, "asset": folder_doc, "subset": product_doc, From 100d1d9ca67883bdb956bfc7ac1cb7471d1c9a5a Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 15:23:16 +0300 Subject: [PATCH 0708/1224] BigRoy's comments: add a commetn and a default value for dictionary get --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 6a1871afdc..a368e77ff7 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -46,11 +46,11 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, if not frame_data: return - # Log artist friendly message about the collected frame range + # Log debug message about the collected frame range frame_start = frame_data["frameStart"] frame_end = frame_data["frameEnd"] - if attr_values.get("use_handles"): + if attr_values.get("use_handles", self.use_asset_handles): self.log.debug( "Full Frame range with Handles " "[{frame_start_handle} - {frame_end_handle}]" @@ -65,6 +65,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, "start and end handles are set to 0." ) + # Log collected frame range to the user message = "Frame range [{frame_start} - {frame_end}]".format( frame_start=frame_start, frame_end=frame_end From 349cf6d35d8544b84d28e4f90e771557dc25da6b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 20:25:16 +0800 Subject: [PATCH 0709/1224] support resolution settings --- openpype/hosts/max/api/preview_animation.py | 58 ++++++++++++++----- .../hosts/max/plugins/create/create_review.py | 12 ++++ .../max/plugins/publish/collect_review.py | 4 +- .../publish/extract_review_animation.py | 2 + 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index c6dd8737a7..601ff65c81 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -24,6 +24,26 @@ def play_preview_when_done(has_autoplay): rt.preferences.playPreviewWhenDone = current_playback +@contextlib.contextmanager +def render_resolution(width, height): + """Function to set render resolution option during + context + + Args: + width (int): render width + height (int): render height + """ + current_renderWidth = rt.renderWidth + current_renderHeight = rt.renderHeight + try: + rt.renderWidth = width + rt.renderHeight = height + yield + finally: + rt.renderWidth = current_renderWidth + rt.renderHeight = current_renderHeight + + @contextlib.contextmanager def viewport_camera(camera): """Function to set viewport camera during context @@ -217,6 +237,7 @@ def publish_preview_animation( instance, staging_dir, ext, review_camera, startFrame=None, endFrame=None, + resolution=None, viewport_options=None): """Render camera review animation @@ -235,25 +256,30 @@ def publish_preview_animation( endFrame = int(rt.animationRange.end) if viewport_options is None: viewport_options = viewport_options_for_preview_animation() + if resolution is None: + resolution = (1920, 1080) with play_preview_when_done(False): with viewport_camera(review_camera): - if int(get_max_version()) < 2024: - with viewport_preference_setting( - viewport_options["general_viewport"], - viewport_options["nitrous_viewport"], - viewport_options["vp_btn_mgr"]): - percentSize = viewport_options.get("percentSize", 100) + width, height = resolution + with render_resolution(width, height): + if int(get_max_version()) < 2024: + with viewport_preference_setting( + viewport_options["general_viewport"], + viewport_options["nitrous_viewport"], + viewport_options["vp_btn_mgr"]): + percentSize = viewport_options.get("percentSize", 100) - publish_preview_sequences( - staging_dir, instance.name, - startFrame, endFrame, percentSize, ext) - else: - fps = instance.data["fps"] - rt.completeRedraw() - preview_arg = publish_review_animation(instance, staging_dir, - startFrame, endFrame, - ext, fps, viewport_options) - rt.execute(preview_arg) + publish_preview_sequences( + staging_dir, instance.name, + startFrame, endFrame, percentSize, ext) + else: + fps = instance.data["fps"] + rt.completeRedraw() + preview_arg = publish_review_animation( + instance, staging_dir, + startFrame, endFrame, + ext, fps, viewport_options) + rt.execute(preview_arg) rt.completeRedraw() diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 977c018f5c..bbcdce90b7 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -18,6 +18,8 @@ class CreateReview(plugin.MaxCreator): "creator_attributes", dict()) for key in ["imageFormat", "keepImages", + "review_width", + "review_height", "percentSize", "visualStyleMode", "viewportPreset", @@ -48,6 +50,16 @@ class CreateReview(plugin.MaxCreator): "DXMode", "Customize"] return [ + NumberDef("review_width", + label="Review width", + decimals=0, + minimum=0, + default=1920), + NumberDef("review_height", + label="Review height", + decimals=0, + minimum=0, + default=1080), BoolDef("keepImages", label="Keep Image Sequences", default=False), diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 904a4eab0f..cfd48edb15 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -34,7 +34,9 @@ class CollectReview(pyblish.api.InstancePlugin, "keepImages": creator_attrs["keepImages"], "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], - "fps": instance.context.data["fps"] + "fps": instance.context.data["fps"], + "resolution": (creator_attrs["review_width"], + creator_attrs["review_height"]) } if int(get_max_version()) >= 2024: diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index d2de981236..c308aadfdb 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -33,10 +33,12 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) + resolution = instance.data.get("resolution", ()) publish_preview_animation( instance, staging_dir, ext, review_camera, startFrame=start, endFrame=end, + resolution=resolution, viewport_options=viewport_options) tags = ["review"] From 0016f8aa3d4dea931ac89ce4c977bff9a442cf45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 15:33:00 +0200 Subject: [PATCH 0710/1224] created copy of push to project tool --- .../tools/ayon_push_to_project/__init__.py | 0 openpype/tools/ayon_push_to_project/app.py | 28 + .../ayon_push_to_project/control_context.py | 678 +++++++++ .../ayon_push_to_project/control_integrate.py | 1210 +++++++++++++++++ openpype/tools/ayon_push_to_project/window.py | 829 +++++++++++ 5 files changed, 2745 insertions(+) create mode 100644 openpype/tools/ayon_push_to_project/__init__.py create mode 100644 openpype/tools/ayon_push_to_project/app.py create mode 100644 openpype/tools/ayon_push_to_project/control_context.py create mode 100644 openpype/tools/ayon_push_to_project/control_integrate.py create mode 100644 openpype/tools/ayon_push_to_project/window.py diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/ayon_push_to_project/app.py b/openpype/tools/ayon_push_to_project/app.py new file mode 100644 index 0000000000..b3ec33f353 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/app.py @@ -0,0 +1,28 @@ +import click + +from openpype.tools.utils import get_openpype_qt_app +from openpype.tools.push_to_project.window import PushToContextSelectWindow + + +@click.command() +@click.option("--project", help="Source project name") +@click.option("--version", help="Source version id") +def main(project, version): + """Run PushToProject tool to integrate version in different project. + + Args: + project (str): Source project name. + version (str): Version id. + """ + + app = get_openpype_qt_app() + + window = PushToContextSelectWindow() + window.show() + window.controller.set_source(project, version) + + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/ayon_push_to_project/control_context.py b/openpype/tools/ayon_push_to_project/control_context.py new file mode 100644 index 0000000000..e4058893d5 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/control_context.py @@ -0,0 +1,678 @@ +import re +import collections +import threading + +from openpype.client import ( + get_projects, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_version_by_id, + get_representations, +) +from openpype.settings import get_project_settings +from openpype.lib import prepare_template_data +from openpype.lib.events import EventSystem +from openpype.pipeline.create import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + get_subset_name_template, +) + +from .control_integrate import ( + ProjectPushItem, + ProjectPushItemProcess, + ProjectPushItemStatus, +) + + +class AssetItem: + def __init__( + self, + entity_id, + name, + icon_name, + icon_color, + parent_id, + has_children + ): + self.id = entity_id + self.name = name + self.icon_name = icon_name + self.icon_color = icon_color + self.parent_id = parent_id + self.has_children = has_children + + @classmethod + def from_doc(cls, asset_doc, has_children=True): + parent_id = asset_doc["data"].get("visualParent") + if parent_id is not None: + parent_id = str(parent_id) + return cls( + str(asset_doc["_id"]), + asset_doc["name"], + asset_doc["data"].get("icon"), + asset_doc["data"].get("color"), + parent_id, + has_children + ) + + +class TaskItem: + def __init__(self, asset_id, name, task_type, short_name): + self.asset_id = asset_id + self.name = name + self.task_type = task_type + self.short_name = short_name + + @classmethod + def from_asset_doc(cls, asset_doc, project_doc): + asset_tasks = asset_doc["data"].get("tasks") or {} + project_task_types = project_doc["config"]["tasks"] + output = [] + for task_name, task_info in asset_tasks.items(): + task_type = task_info.get("type") + task_type_info = project_task_types.get(task_type) or {} + output.append(cls( + asset_doc["_id"], + task_name, + task_type, + task_type_info.get("short_name") + )) + return output + + +class EntitiesModel: + def __init__(self, event_system): + self._event_system = event_system + self._project_names = None + self._project_docs_by_name = {} + self._assets_by_project = {} + self._tasks_by_asset_id = collections.defaultdict(dict) + + def has_cached_projects(self): + return self._project_names is None + + def has_cached_assets(self, project_name): + if not project_name: + return True + return project_name in self._assets_by_project + + def has_cached_tasks(self, project_name): + return self.has_cached_assets(project_name) + + def get_projects(self): + if self._project_names is None: + self.refresh_projects() + return list(self._project_names) + + def get_assets(self, project_name): + if project_name not in self._assets_by_project: + self.refresh_assets(project_name) + return dict(self._assets_by_project[project_name]) + + def get_asset_by_id(self, project_name, asset_id): + return self._assets_by_project[project_name].get(asset_id) + + def get_tasks(self, project_name, asset_id): + if not project_name or not asset_id: + return [] + + if project_name not in self._tasks_by_asset_id: + self.refresh_assets(project_name) + + all_task_items = self._tasks_by_asset_id[project_name] + asset_task_items = all_task_items.get(asset_id) + if not asset_task_items: + return [] + return list(asset_task_items) + + def refresh_projects(self, force=False): + self._event_system.emit( + "projects.refresh.started", {}, "entities.model" + ) + if force or self._project_names is None: + project_names = [] + project_docs_by_name = {} + for project_doc in get_projects(): + library_project = project_doc["data"].get("library_project") + if not library_project: + continue + project_name = project_doc["name"] + project_names.append(project_name) + project_docs_by_name[project_name] = project_doc + self._project_names = project_names + self._project_docs_by_name = project_docs_by_name + self._event_system.emit( + "projects.refresh.finished", {}, "entities.model" + ) + + def _refresh_assets(self, project_name): + asset_items_by_id = {} + task_items_by_asset_id = {} + self._assets_by_project[project_name] = asset_items_by_id + self._tasks_by_asset_id[project_name] = task_items_by_asset_id + if not project_name: + return + + project_doc = self._project_docs_by_name[project_name] + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in get_assets(project_name): + parent_id = asset_doc["data"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + hierarchy_queue = collections.deque() + for asset_doc in asset_docs_by_parent_id[None]: + hierarchy_queue.append(asset_doc) + + while hierarchy_queue: + asset_doc = hierarchy_queue.popleft() + children = asset_docs_by_parent_id[asset_doc["_id"]] + asset_item = AssetItem.from_doc(asset_doc, len(children) > 0) + asset_items_by_id[asset_item.id] = asset_item + task_items_by_asset_id[asset_item.id] = ( + TaskItem.from_asset_doc(asset_doc, project_doc) + ) + for child in children: + hierarchy_queue.append(child) + + def refresh_assets(self, project_name, force=False): + self._event_system.emit( + "assets.refresh.started", + {"project_name": project_name}, + "entities.model" + ) + + if force or project_name not in self._assets_by_project: + self._refresh_assets(project_name) + + self._event_system.emit( + "assets.refresh.finished", + {"project_name": project_name}, + "entities.model" + ) + + +class SelectionModel: + def __init__(self, event_system): + self._event_system = event_system + + self.project_name = None + self.asset_id = None + self.task_name = None + + def select_project(self, project_name): + if self.project_name == project_name: + return + + self.project_name = project_name + self._event_system.emit( + "project.changed", + {"project_name": project_name}, + "selection.model" + ) + + def select_asset(self, asset_id): + if self.asset_id == asset_id: + return + self.asset_id = asset_id + self._event_system.emit( + "asset.changed", + { + "project_name": self.project_name, + "asset_id": asset_id + }, + "selection.model" + ) + + def select_task(self, task_name): + if self.task_name == task_name: + return + self.task_name = task_name + self._event_system.emit( + "task.changed", + { + "project_name": self.project_name, + "asset_id": self.asset_id, + "task_name": task_name + }, + "selection.model" + ) + + +class UserPublishValues: + """Helper object to validate values required for push to different project. + + Args: + event_system (EventSystem): Event system to catch and emit events. + new_asset_name (str): Name of new asset name. + variant (str): Variant for new subset name in new project. + """ + + asset_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, event_system): + self._event_system = event_system + self._new_asset_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_asset_name_valid = False + + self.set_new_asset("") + self.set_variant("") + self.set_comment("") + + @property + def new_asset_name(self): + return self._new_asset_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_asset_name_valid(self): + return self._is_new_asset_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_asset_name_valid + + def set_variant(self, variant): + if variant == self._variant: + return + + old_variant = self._variant + old_is_valid = self._is_variant_valid + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("variant", old_variant, variant), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + "changes": changes + }, + "user_values" + ) + + def set_new_asset(self, asset_name): + if self._new_asset_name == asset_name: + return + old_asset_name = self._new_asset_name + old_is_valid = self._is_new_asset_name_valid + self._new_asset_name = asset_name + is_valid = True + if asset_name: + is_valid = ( + self.asset_name_regex.match(asset_name) is not None + ) + self._is_new_asset_name_valid = is_valid + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("new_asset_name", old_asset_name, asset_name), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "new_asset_name.changed", + { + "new_asset_name": self._new_asset_name, + "is_valid": self._is_new_asset_name_valid, + "changes": changes + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + old_comment = self._comment + self._comment = comment + self._event_system.emit( + "comment.changed", + { + "comment": comment, + "changes": { + "comment": {"new": comment, "old": old_comment} + } + }, + "user_values" + ) + + +class PushToContextController: + def __init__(self, project_name=None, version_id=None): + self._src_project_name = None + self._src_version_id = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + + event_system = EventSystem() + entities_model = EntitiesModel(event_system) + selection_model = SelectionModel(event_system) + user_values = UserPublishValues(event_system) + + self._event_system = event_system + self._entities_model = entities_model + self._selection_model = selection_model + self._user_values = user_values + + event_system.add_callback("project.changed", self._on_project_change) + event_system.add_callback("asset.changed", self._invalidate) + event_system.add_callback("variant.changed", self._invalidate) + event_system.add_callback("new_asset_name.changed", self._invalidate) + + self._submission_enabled = False + self._process_thread = None + self._process_item = None + + self.set_source(project_name, version_id) + + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): + asset_tasks = asset_doc["data"].get("tasks") or {} + found_comb = [] + for repre_doc in repre_docs: + context = repre_doc["context"] + task_info = context.get("task") + if task_info is None: + continue + + task_name = None + task_type = None + if isinstance(task_info, str): + task_name = task_info + asset_task_info = asset_tasks.get(task_info) or {} + task_type = asset_task_info.get("type") + + elif isinstance(task_info, dict): + task_name = task_info.get("name") + task_type = task_info.get("type") + + if task_name and task_type: + return task_name, task_type + + if task_name: + found_comb.append((task_name, task_type)) + + for task_name, task_type in found_comb: + return task_name, task_type + return None, None + + def _get_src_variant(self): + project_name = self._src_project_name + version_doc = self._src_version_doc + asset_doc = self._src_asset_doc + repre_docs = get_representations( + project_name, version_ids=[version_doc["_id"]] + ) + task_name, task_type = self._get_task_info_from_repre_docs( + asset_doc, repre_docs + ) + + project_settings = get_project_settings(project_name) + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + if not family: + family = subset_doc["data"]["families"][0] + template = get_subset_name_template( + self._src_project_name, + family, + task_name, + task_type, + None, + project_settings=project_settings + ) + template_low = template.lower() + variant_placeholder = "{variant}" + if ( + variant_placeholder not in template_low + or (not task_name and "{task" in template_low) + ): + return "" + + idx = template_low.index(variant_placeholder) + template_s = template[:idx] + template_e = template[idx + len(variant_placeholder):] + fill_data = prepare_template_data({ + "family": family, + "task": task_name + }) + try: + subset_s = template_s.format(**fill_data) + subset_e = template_e.format(**fill_data) + except Exception as exc: + print("Failed format", exc) + return "" + + subset_name = self.src_subset_doc["name"] + if ( + (subset_s and not subset_name.startswith(subset_s)) + or (subset_e and not subset_name.endswith(subset_e)) + ): + return "" + + if subset_s: + subset_name = subset_name[len(subset_s):] + if subset_e: + subset_name = subset_name[:len(subset_e)] + return subset_name + + def set_source(self, project_name, version_id): + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self.user_values.set_new_asset(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self.user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self.user_values.set_comment(comment) + + self._event_system.emit( + "source.changed", { + "project_name": project_name, + "version_id": version_id + }, + "controller" + ) + + @property + def src_project_name(self): + return self._src_project_name + + @property + def src_version_id(self): + return self._src_version_id + + @property + def src_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self.src_asset_doc + if not asset_doc: + return "Source is invalid" + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + subset_doc = self.src_subset_doc + version_doc = self.src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + asset_path, + subset_doc["name"], + version_doc["name"] + ) + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def event_system(self): + return self._event_system + + @property + def model(self): + return self._entities_model + + @property + def selection_model(self): + return self._selection_model + + @property + def user_values(self): + return self._user_values + + @property + def submission_enabled(self): + return self._submission_enabled + + def _on_project_change(self, event): + project_name = event["project_name"] + self.model.refresh_assets(project_name) + self._invalidate() + + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._event_system.emit( + "submission.enabled.changed", + {"enabled": submission_enabled}, + "controller" + ) + + def _check_submit_validations(self): + if not self._user_values.is_valid: + return False + + if not self.selection_model.project_name: + return False + + if ( + not self._user_values.new_asset_name + and not self.selection_model.asset_id + ): + return False + + return True + + def get_selected_asset_name(self): + project_name = self._selection_model.project_name + asset_id = self._selection_model.asset_id + if not project_name or not asset_id: + return None + asset_item = self._entities_model.get_asset_by_id( + project_name, asset_id + ) + if asset_item: + return asset_item.name + return None + + def submit(self, wait=True): + if not self.submission_enabled: + return + + if self._process_thread is not None: + return + + item = ProjectPushItem( + self.src_project_name, + self.src_version_id, + self.selection_model.project_name, + self.selection_model.asset_id, + self.selection_model.task_name, + self.user_values.variant, + comment=self.user_values.comment, + new_asset_name=self.user_values.new_asset_name, + dst_version=1 + ) + + status_item = ProjectPushItemStatus(event_system=self._event_system) + process_item = ProjectPushItemProcess(item, status_item) + self._process_item = process_item + self._event_system.emit("submit.started", {}, "controller") + if wait: + self._submit_callback() + self._process_item = None + return process_item + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return process_item + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _submit_callback(self): + process_item = self._process_item + if process_item is None: + return + process_item.process() + self._event_system.emit("submit.finished", {}, "controller") + if process_item is self._process_item: + self._process_item = None diff --git a/openpype/tools/ayon_push_to_project/control_integrate.py b/openpype/tools/ayon_push_to_project/control_integrate.py new file mode 100644 index 0000000000..a822339ccf --- /dev/null +++ b/openpype/tools/ayon_push_to_project/control_integrate.py @@ -0,0 +1,1210 @@ +import os +import re +import copy +import socket +import itertools +import datetime +import sys +import traceback + +from bson.objectid import ObjectId + +from openpype.client import ( + get_project, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_subset_by_name, + get_version_by_id, + get_last_version_by_subset_id, + get_version_by_name, + get_representations, +) +from openpype.client.operations import ( + OperationsSession, + new_asset_document, + new_subset_document, + new_version_doc, + new_representation_doc, + prepare_version_update_data, + prepare_representation_update_data, +) +from openpype.modules import ModulesManager +from openpype.lib import ( + StringTemplate, + get_openpype_username, + get_formatted_current_time, + source_hash, +) + +from openpype.lib.file_transaction import FileTransaction +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy +from openpype.pipeline.version_start import get_versioning_start +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.publish import get_publish_template_name +from openpype.pipeline.create import get_subset_name + +UNKNOWN = object() + + +class PushToProjectError(Exception): + pass + + +class FileItem(object): + def __init__(self, path): + self.path = path + + @property + def is_valid_file(self): + return os.path.exists(self.path) and os.path.isfile(self.path) + + +class SourceFile(FileItem): + def __init__(self, path, frame=None, udim=None): + super(SourceFile, self).__init__(path) + self.frame = frame + self.udim = udim + + def __repr__(self): + subparts = [self.__class__.__name__] + if self.frame is not None: + subparts.append("frame: {}".format(self.frame)) + if self.udim is not None: + subparts.append("UDIM: {}".format(self.udim)) + + return "<{}> '{}'".format(" - ".join(subparts), self.path) + + +class ResourceFile(FileItem): + def __init__(self, path, relative_path): + super(ResourceFile, self).__init__(path) + self.relative_path = relative_path + + def __repr__(self): + return "<{}> '{}'".format(self.__class__.__name__, self.relative_path) + + @property + def is_valid_file(self): + if not self.relative_path: + return False + return super(ResourceFile, self).is_valid_file + + +class ProjectPushItem: + def __init__( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_asset_id, + dst_task_name, + variant, + comment=None, + new_asset_name=None, + dst_version=None + ): + self.src_project_name = src_project_name + self.src_version_id = src_version_id + self.dst_project_name = dst_project_name + self.dst_asset_id = dst_asset_id + self.dst_task_name = dst_task_name + self.dst_version = dst_version + self.variant = variant + self.new_asset_name = new_asset_name + self.comment = comment or "" + self._id = "|".join([ + src_project_name, + src_version_id, + dst_project_name, + str(dst_asset_id), + str(new_asset_name), + str(dst_task_name), + str(dst_version) + ]) + + @property + def id(self): + return self._id + + def __repr__(self): + return "<{} - {}>".format(self.__class__.__name__, self.id) + + +class StatusMessage: + def __init__(self, message, level): + self.message = message + self.level = level + + def __str__(self): + return "{}: {}".format(self.level.upper(), self.message) + + def __repr__(self): + return "<{} - {}> {}".format( + self.__class__.__name__, self.level.upper, self.message + ) + + +class ProjectPushItemStatus: + def __init__( + self, + failed=False, + finished=False, + fail_reason=None, + formatted_traceback=None, + messages=None, + event_system=None + ): + if messages is None: + messages = [] + self._failed = failed + self._finished = finished + self._fail_reason = fail_reason + self._traceback = formatted_traceback + self._messages = messages + self._event_system = event_system + + def emit_event(self, topic, data=None): + if self._event_system is None: + return + + self._event_system.emit(topic, data or {}, "push.status") + + def get_finished(self): + """Processing of push to project finished. + + Returns: + bool: Finished. + """ + + return self._finished + + def set_finished(self, finished=True): + """Mark status as finished. + + Args: + finished (bool): Processing finished (failed or not). + """ + + if finished != self._finished: + self._finished = finished + self.emit_event("push.finished.changed", {"finished": finished}) + + finished = property(get_finished, set_finished) + + def set_failed(self, fail_reason, exc_info=None): + """Set status as failed. + + Attribute 'fail_reason' can change automatically based on passed value. + Reason is unset if 'failed' is 'False' and is set do default reason if + is set to 'True' and reason is not set. + + Args: + failed (bool): Push to project failed. + fail_reason (str): Reason why failed. + """ + + failed = True + if not fail_reason and not exc_info: + failed = False + + full_traceback = None + if exc_info is not None: + full_traceback = "".join(traceback.format_exception(*exc_info)) + if not fail_reason: + fail_reason = "Failed without specified reason" + + if ( + self._failed == failed + and self._traceback == full_traceback + and self._fail_reason == fail_reason + ): + return + + self._failed = failed + self._fail_reason = fail_reason or None + self._traceback = full_traceback + + self.emit_event( + "push.failed.changed", + { + "failed": failed, + "reason": fail_reason, + "traceback": full_traceback + } + ) + + @property + def failed(self): + """Processing failed. + + Returns: + bool: Processing failed. + """ + + return self._failed + + @property + def fail_reason(self): + """Reason why push to process failed. + + Returns: + Union[str, None]: Reason why push failed or None. + """ + + return self._fail_reason + + @property + def traceback(self): + """Traceback of failed process. + + Traceback is available only if unhandled exception happened. + + Returns: + Union[str, None]: Formatted traceback. + """ + + return self._traceback + + # Loggin helpers + # TODO better logging + def add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self.emit_event( + "push.message.added", + {"message": message, "level": level} + ) + print(message_obj) + return message_obj + + def debug(self, message): + return self.add_message(message, "debug") + + def info(self, message): + return self.add_message(message, "info") + + def warning(self, message): + return self.add_message(message, "warning") + + def error(self, message): + return self.add_message(message, "error") + + def critical(self, message): + return self.add_message(message, "critical") + + +class ProjectPushRepreItem: + """Representation item. + + Representation item based on representation document and project roots. + + Representation document may have reference to: + - source files: Files defined with publish template + - resource files: Files that should be in publish directory + but filenames are not template based. + + Args: + repre_doc (Dict[str, Ant]): Representation document. + roots (Dict[str, str]): Project roots (based on project anatomy). + """ + + def __init__(self, repre_doc, roots): + self._repre_doc = repre_doc + self._roots = roots + self._src_files = None + self._resource_files = None + self._frame = UNKNOWN + + @property + def repre_doc(self): + return self._repre_doc + + @property + def src_files(self): + if self._src_files is None: + self.get_source_files() + return self._src_files + + @property + def resource_files(self): + if self._resource_files is None: + self.get_source_files() + return self._resource_files + + @staticmethod + def _clean_path(path): + new_value = path.replace("\\", "/") + while "//" in new_value: + new_value = new_value.replace("//", "/") + return new_value + + @staticmethod + def _get_relative_path(path, src_dirpath): + dirpath, basename = os.path.split(path) + if not dirpath.lower().startswith(src_dirpath.lower()): + return None + + relative_dir = dirpath[len(src_dirpath):].lstrip("/") + if relative_dir: + relative_path = "/".join([relative_dir, basename]) + else: + relative_path = basename + return relative_path + + @property + def frame(self): + """First frame of representation files. + + This value will be in representation document context if is sequence. + + Returns: + Union[int, None]: First frame in representation files based on + source files or None if frame is not part of filename. + """ + + if self._frame is UNKNOWN: + frame = None + for src_file in self.src_files: + src_frame = src_file.frame + if ( + src_frame is not None + and (frame is None or src_frame < frame) + ): + frame = src_frame + self._frame = frame + return self._frame + + @staticmethod + def validate_source_files(src_files, resource_files): + if not src_files: + raise AssertionError(( + "Couldn't figure out source files from representation." + " Found resource files {}" + ).format(", ".join(str(i) for i in resource_files))) + + invalid_items = [ + item + for item in itertools.chain(src_files, resource_files) + if not item.is_valid_file + ] + if invalid_items: + raise AssertionError(( + "Source files that were not found on disk: {}" + ).format(", ".join(str(i) for i in invalid_items))) + + def get_source_files(self): + if self._src_files is not None: + return self._src_files, self._resource_files + + repre_context = self._repre_doc["context"] + if "frame" in repre_context or "udim" in repre_context: + src_files, resource_files = self._get_source_files_with_frames() + else: + src_files, resource_files = self._get_source_files() + + self.validate_source_files(src_files, resource_files) + + self._src_files = src_files + self._resource_files = resource_files + return self._src_files, self._resource_files + + def _get_source_files_with_frames(self): + frame_placeholder = "__frame__" + udim_placeholder = "__udim__" + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + # Remove padding from 'udim' and 'frame' formatting keys + # - "{frame:0>4}" -> "{frame}" + for key in ("udim", "frame"): + sub_part = "{" + key + "[^}]*}" + replacement = "{{{}}}".format(key) + template = re.sub(sub_part, replacement, template) + + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + if "frame" in fill_repre_context: + fill_repre_context["frame"] = frame_placeholder + + if "udim" in fill_repre_context: + fill_repre_context["udim"] = udim_placeholder + + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template( + template, fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath, src_basename = os.path.split(repre_path) + src_basename = ( + re.escape(src_basename) + .replace(frame_placeholder, "(?P[0-9]+)") + .replace(udim_placeholder, "(?P[0-9]+)") + ) + src_basename_regex = re.compile("^{}$".format(src_basename)) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots) + ) + dirpath, basename = os.path.split(filepath_template) + if ( + dirpath.lower() != src_dirpath.lower() + or not src_basename_regex.match(basename) + ): + relative_path = self._get_relative_path(filepath, src_dirpath) + resource_files.append(ResourceFile(filepath, relative_path)) + continue + + filepath = os.path.join(src_dirpath, basename) + frame = None + udim = None + for item in src_basename_regex.finditer(basename): + group_name = item.lastgroup + value = item.group(group_name) + if group_name == "frame": + frame = int(value) + elif group_name == "udim": + udim = value + + src_files.append(SourceFile(filepath, frame, udim)) + + return src_files, resource_files + + def _get_source_files(self): + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template(template, + fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath = os.path.dirname(repre_path) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots)) + + if filepath_template.lower() == repre_path.lower(): + src_files.append( + SourceFile(repre_path.format(root=self._roots)) + ) + else: + relative_path = self._get_relative_path( + filepath_template, src_dirpath + ) + resource_files.append( + ResourceFile(filepath, relative_path) + ) + return src_files, resource_files + + +class ProjectPushItemProcess: + """ + Args: + item (ProjectPushItem): Item which is being processed. + item_status (ProjectPushItemStatus): Object to store status. + """ + + # TODO where to get host?!!! + host_name = "republisher" + + def __init__(self, item, item_status=None): + self._item = item + + self._src_project_doc = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + self._src_repre_items = None + self._src_anatomy = None + + self._project_doc = None + self._anatomy = None + self._asset_doc = None + self._created_asset_doc = None + self._task_info = None + self._subset_doc = None + self._version_doc = None + + self._family = None + self._subset_name = None + + self._project_settings = None + self._template_name = None + + if item_status is None: + item_status = ProjectPushItemStatus() + self._status = item_status + self._operations = OperationsSession() + self._file_transaction = FileTransaction() + + @property + def status(self): + return self._status + + @property + def src_project_doc(self): + return self._src_project_doc + + @property + def src_anatomy(self): + return self._src_anatomy + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_repre_items(self): + return self._src_repre_items + + @property + def project_doc(self): + return self._project_doc + + @property + def anatomy(self): + return self._anatomy + + @property + def project_settings(self): + return self._project_settings + + @property + def asset_doc(self): + return self._asset_doc + + @property + def task_info(self): + return self._task_info + + @property + def subset_doc(self): + return self._subset_doc + + @property + def version_doc(self): + return self._version_doc + + @property + def variant(self): + return self._item.variant + + @property + def family(self): + return self._family + + @property + def subset_name(self): + return self._subset_name + + @property + def template_name(self): + return self._template_name + + def fill_source_variables(self): + src_project_name = self._item.src_project_name + src_version_id = self._item.src_version_id + + project_doc = get_project(src_project_name) + if not project_doc: + self._status.set_failed( + f"Source project \"{src_project_name}\" was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(f"Project '{src_project_name}' found") + + version_doc = get_version_by_id(src_project_name, src_version_id) + if not version_doc: + self._status.set_failed(( + f"Source version with id \"{src_version_id}\"" + f" was not found in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + subset_id = version_doc["parent"] + subset_doc = get_subset_by_id(src_project_name, subset_id) + if not subset_doc: + self._status.set_failed(( + f"Could find subset with id \"{subset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + asset_id = subset_doc["parent"] + asset_doc = get_asset_by_id(src_project_name, asset_id) + if not asset_doc: + self._status.set_failed(( + f"Could find asset with id \"{asset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + anatomy = Anatomy(src_project_name) + + repre_docs = get_representations( + src_project_name, + version_ids=[src_version_id] + ) + repre_items = [ + ProjectPushRepreItem(repre_doc, anatomy.roots) + for repre_doc in repre_docs + ] + self._status.debug(( + f"Found {len(repre_items)} representations on" + f" version {src_version_id} in project '{src_project_name}'" + )) + if not repre_items: + self._status.set_failed( + "Source version does not have representations" + f" (Version id: {src_version_id})" + ) + raise PushToProjectError(self._status.fail_reason) + + self._src_anatomy = anatomy + self._src_project_doc = project_doc + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + self._src_repre_items = repre_items + + def fill_destination_project(self): + # --- Destination entities --- + dst_project_name = self._item.dst_project_name + # Validate project existence + dst_project_doc = get_project(dst_project_name) + if not dst_project_doc: + self._status.set_failed( + f"Destination project '{dst_project_name}' was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Destination project '{dst_project_name}' found" + ) + self._project_doc = dst_project_doc + self._anatomy = Anatomy(dst_project_name) + self._project_settings = get_project_settings( + self._item.dst_project_name + ) + + def _create_asset( + self, + src_asset_doc, + project_doc, + parent_asset_doc, + asset_name + ): + parent_id = None + parents = [] + tools = [] + if parent_asset_doc: + parent_id = parent_asset_doc["_id"] + parents = list(parent_asset_doc["data"]["parents"]) + parents.append(parent_asset_doc["name"]) + _tools = parent_asset_doc["data"].get("tools_env") + if _tools: + tools = list(_tools) + + asset_name_low = asset_name.lower() + other_asset_docs = get_assets( + project_doc["name"], fields=["_id", "name", "data.visualParent"] + ) + for other_asset_doc in other_asset_docs: + other_name = other_asset_doc["name"] + other_parent_id = other_asset_doc["data"].get("visualParent") + if other_name.lower() != asset_name_low: + continue + + if other_parent_id != parent_id: + self._status.set_failed(( + f"Asset with name \"{other_name}\" already" + " exists in different hierarchy." + )) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(( + f"Found already existing asset with name \"{other_name}\"" + f" which match requested name \"{asset_name}\"" + )) + return get_asset_by_id(project_doc["name"], other_asset_doc["_id"]) + + data_keys = ( + "clipIn", + "clipOut", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "resolutionWidth", + "resolutionHeight", + "fps", + "pixelAspect", + ) + asset_data = { + "visualParent": parent_id, + "parents": parents, + "tasks": {}, + "tools_env": tools + } + src_asset_data = src_asset_doc["data"] + for key in data_keys: + if key in src_asset_data: + asset_data[key] = src_asset_data[key] + + asset_doc = new_asset_document( + asset_name, + project_doc["_id"], + parent_id, + parents, + data=asset_data + ) + self._operations.create_entity( + project_doc["name"], + asset_doc["type"], + asset_doc + ) + self._status.info( + f"Creating new asset with name \"{asset_name}\"" + ) + self._created_asset_doc = asset_doc + return asset_doc + + def fill_or_create_destination_asset(self): + dst_project_name = self._item.dst_project_name + dst_asset_id = self._item.dst_asset_id + dst_task_name = self._item.dst_task_name + new_asset_name = self._item.new_asset_name + if not dst_asset_id and not new_asset_name: + self._status.set_failed( + "Push item does not have defined destination asset" + ) + raise PushToProjectError(self._status.fail_reason) + + # Get asset document + parent_asset_doc = None + if dst_asset_id: + parent_asset_doc = get_asset_by_id( + self._item.dst_project_name, self._item.dst_asset_id + ) + if not parent_asset_doc: + self._status.set_failed( + f"Could find asset with id \"{dst_asset_id}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + if not new_asset_name: + asset_doc = parent_asset_doc + else: + asset_doc = self._create_asset( + self.src_asset_doc, + self.project_doc, + parent_asset_doc, + new_asset_name + ) + self._asset_doc = asset_doc + if not dst_task_name: + self._task_info = {} + return + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(dst_task_name) + if not task_info: + self._status.set_failed( + f"Could find task with name \"{dst_task_name}\"" + f" on asset \"{asset_path}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + # Create copy of task info to avoid changing data in asset document + task_info = copy.deepcopy(task_info) + task_info["name"] = dst_task_name + # Fill rest of task information based on task type + task_type = task_info["type"] + task_type_info = self.project_doc["config"]["tasks"].get(task_type, {}) + task_info.update(task_type_info) + self._task_info = task_info + + def determine_family(self): + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + families = subset_doc["data"].get("families") + if not family and families: + family = families[0] + + if not family: + self._status.set_failed( + "Couldn't figure out family from source subset" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Publishing family is '{family}' (Based on source subset)" + ) + self._family = family + + def determine_publish_template_name(self): + template_name = get_publish_template_name( + self._item.dst_project_name, + self.host_name, + self.family, + self.task_info.get("name"), + self.task_info.get("type"), + project_settings=self.project_settings + ) + self._status.debug( + f"Using template '{template_name}' for integration" + ) + self._template_name = template_name + + def determine_subset_name(self): + family = self.family + asset_doc = self.asset_doc + task_info = self.task_info + subset_name = get_subset_name( + family, + self.variant, + task_info.get("name"), + asset_doc, + project_name=self._item.dst_project_name, + host_name=self.host_name, + project_settings=self.project_settings + ) + self._status.info( + f"Push will be integrating to subset with name '{subset_name}'" + ) + self._subset_name = subset_name + + def make_sure_subset_exists(self): + project_name = self._item.dst_project_name + asset_id = self.asset_doc["_id"] + subset_name = self.subset_name + family = self.family + subset_doc = get_subset_by_name(project_name, subset_name, asset_id) + if subset_doc: + self._subset_doc = subset_doc + return subset_doc + + data = { + "families": [family] + } + subset_doc = new_subset_document( + subset_name, family, asset_id, data + ) + self._operations.create_entity(project_name, "subset", subset_doc) + self._subset_doc = subset_doc + + def make_sure_version_exists(self): + """Make sure version document exits in database.""" + + project_name = self._item.dst_project_name + version = self._item.dst_version + src_version_doc = self.src_version_doc + subset_doc = self.subset_doc + subset_id = subset_doc["_id"] + src_data = src_version_doc["data"] + families = subset_doc["data"].get("families") + if not families: + families = [subset_doc["data"]["family"]] + + version_data = { + "families": list(families), + "fps": src_data.get("fps"), + "source": src_data.get("source"), + "machine": socket.gethostname(), + "comment": self._item.comment or "", + "author": get_openpype_username(), + "time": get_formatted_current_time(), + } + if version is None: + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id + ) + if last_version_doc: + version = int(last_version_doc["name"]) + 1 + else: + version = get_versioning_start( + project_name, + self.host_name, + task_name=self.task_info["name"], + task_type=self.task_info["type"], + family=families[0], + subset=subset_doc["name"] + ) + + existing_version_doc = get_version_by_name( + project_name, version, subset_id + ) + # Update existing version + if existing_version_doc: + version_doc = new_version_doc( + version, subset_id, version_data, existing_version_doc["_id"] + ) + update_data = prepare_version_update_data( + existing_version_doc, version_doc + ) + if update_data: + self._operations.update_entity( + project_name, + "version", + existing_version_doc["_id"], + update_data + ) + self._version_doc = version_doc + + return + + version_doc = new_version_doc( + version, subset_id, version_data + ) + self._operations.create_entity(project_name, "version", version_doc) + + self._version_doc = version_doc + + def integrate_representations(self): + try: + self._integrate_representations() + except Exception: + self._operations.clear() + self._file_transaction.rollback() + raise + + def _integrate_representations(self): + version_doc = self.version_doc + version_id = version_doc["_id"] + existing_repres = get_representations( + self._item.dst_project_name, + version_ids=[version_id] + ) + existing_repres_by_low_name = { + repre_doc["name"].lower(): repre_doc + for repre_doc in existing_repres + } + template_name = self.template_name + anatomy = self.anatomy + formatting_data = get_template_data( + self.project_doc, + self.asset_doc, + self.task_info.get("name"), + self.host_name + ) + formatting_data.update({ + "subset": self.subset_name, + "family": self.family, + "version": version_doc["name"] + }) + + path_template = anatomy.templates[template_name]["path"].replace( + "\\", "/" + ) + file_template = StringTemplate( + anatomy.templates[template_name]["file"] + ) + self._status.info("Preparing files to transfer") + processed_repre_items = self._prepare_file_transactions( + anatomy, template_name, formatting_data, file_template + ) + self._file_transaction.process() + self._status.info("Preparing database changes") + self._prepare_database_operations( + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ) + self._status.info("Finalization") + self._operations.commit() + self._file_transaction.finalize() + + def _prepare_file_transactions( + self, anatomy, template_name, formatting_data, file_template + ): + processed_repre_items = [] + for repre_item in self.src_repre_items: + repre_doc = repre_item.repre_doc + repre_name = repre_doc["name"] + repre_format_data = copy.deepcopy(formatting_data) + repre_format_data["representation"] = repre_name + for src_file in repre_item.src_files: + ext = os.path.splitext(src_file.path)[-1] + repre_format_data["ext"] = ext[1:] + break + + template_obj = anatomy.templates_obj[template_name]["folder"] + folder_path = template_obj.format_strict(formatting_data) + repre_context = folder_path.used_values + folder_path_rootless = folder_path.rootless + repre_filepaths = [] + published_path = None + for src_file in repre_item.src_files: + file_data = copy.deepcopy(repre_format_data) + frame = src_file.frame + if frame is not None: + file_data["frame"] = frame + + udim = src_file.udim + if udim is not None: + file_data["udim"] = udim + + filename = file_template.format_strict(file_data) + dst_filepath = os.path.normpath( + os.path.join(folder_path, filename) + ) + dst_rootless_path = os.path.normpath( + os.path.join(folder_path_rootless, filename) + ) + if published_path is None or frame == repre_item.frame: + published_path = dst_filepath + repre_context.update(filename.used_values) + + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(src_file.path, dst_filepath) + + for resource_file in repre_item.resource_files: + dst_filepath = os.path.normpath( + os.path.join(folder_path, resource_file.relative_path) + ) + dst_rootless_path = os.path.normpath( + os.path.join( + folder_path_rootless, resource_file.relative_path + ) + ) + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(resource_file.path, dst_filepath) + processed_repre_items.append( + (repre_item, repre_filepaths, repre_context, published_path) + ) + return processed_repre_items + + def _prepare_database_operations( + self, + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ): + modules_manager = ModulesManager() + sync_server_module = modules_manager.get("sync_server") + if sync_server_module is None or not sync_server_module.enabled: + sites = [{ + "name": "studio", + "created_dt": datetime.datetime.now() + }] + else: + sites = sync_server_module.compute_resource_sync_sites( + project_name=self._item.dst_project_name + ) + + added_repre_names = set() + for item in processed_repre_items: + (repre_item, repre_filepaths, repre_context, published_path) = item + repre_name = repre_item.repre_doc["name"] + added_repre_names.add(repre_name.lower()) + new_repre_data = { + "path": published_path, + "template": path_template + } + new_repre_files = [] + for (path, rootless_path) in repre_filepaths: + new_repre_files.append({ + "_id": ObjectId(), + "path": rootless_path, + "size": os.path.getsize(path), + "hash": source_hash(path), + "sites": sites + }) + + existing_repre = existing_repres_by_low_name.get( + repre_name.lower() + ) + entity_id = None + if existing_repre: + entity_id = existing_repre["_id"] + new_repre_doc = new_representation_doc( + repre_name, + version_id, + repre_context, + data=new_repre_data, + entity_id=entity_id + ) + new_repre_doc["files"] = new_repre_files + if not existing_repre: + self._operations.create_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc + ) + else: + update_data = prepare_representation_update_data( + existing_repre, new_repre_doc + ) + if update_data: + self._operations.update_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc["_id"], + update_data + ) + + existing_repre_names = set(existing_repres_by_low_name.keys()) + for repre_name in (existing_repre_names - added_repre_names): + repre_doc = existing_repres_by_low_name[repre_name] + self._operations.update_entity( + self._item.dst_project_name, + repre_doc["type"], + repre_doc["_id"], + {"type": "archived_representation"} + ) + + def process(self): + try: + self._status.info("Process started") + self.fill_source_variables() + self._status.info("Source entities were found") + self.fill_destination_project() + self._status.info("Destination project was found") + self.fill_or_create_destination_asset() + self._status.info("Destination asset was determined") + self.determine_family() + self.determine_publish_template_name() + self.determine_subset_name() + self.make_sure_subset_exists() + self.make_sure_version_exists() + self._status.info("Prerequirements were prepared") + self.integrate_representations() + self._status.info("Integration finished") + + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) + + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) + + finally: + self._status.set_finished() diff --git a/openpype/tools/ayon_push_to_project/window.py b/openpype/tools/ayon_push_to_project/window.py new file mode 100644 index 0000000000..dc5eab5787 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/window.py @@ -0,0 +1,829 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import load_stylesheet, get_app_icon_path +from openpype.tools.utils import ( + PlaceholderLineEdit, + SeparatorWidget, + get_asset_icon_by_name, + set_style_property, +) +from openpype.tools.utils.views import DeselectableTreeView + +from .control_context import PushToContextController + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 +ASSET_ID_ROLE = QtCore.Qt.UserRole + 3 +TASK_NAME_ROLE = QtCore.Qt.UserRole + 4 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class ProjectsModel(QtGui.QStandardItemModel): + empty_text = "< Empty >" + select_project_text = "< Select Project >" + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsModel, self).__init__() + self._controller = controller + + self.event_system.add_callback( + "projects.refresh.finished", self._on_refresh_finish + ) + + placeholder_item = QtGui.QStandardItem(self.empty_text) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._items = items + + @property + def event_system(self): + return self._controller.event_system + + def _on_refresh_finish(self): + root_item = self.invisibleRootItem() + project_names = self._controller.model.get_projects() + + if not project_names: + placeholder_text = self.empty_text + else: + placeholder_text = self.select_project_text + self._placeholder_item.setData(placeholder_text, QtCore.Qt.DisplayRole) + + new_items = [] + if None not in self._items: + new_items.append(self._placeholder_item) + + current_project_names = set(self._items.keys()) + for project_name in current_project_names - set(project_names): + if project_name is None: + continue + item = self._items.pop(project_name) + root_item.takeRow(item.row()) + + for project_name in project_names: + if project_name in self._items: + continue + item = QtGui.QStandardItem(project_name) + item.setData(project_name, PROJECT_NAME_ROLE) + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + self.refreshed.emit() + + +class ProjectProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self): + super(ProjectProxyModel, self).__init__() + self._filter_empty_projects = False + + def set_filter_empty_project(self, filter_empty_projects): + if filter_empty_projects == self._filter_empty_projects: + return + self._filter_empty_projects = filter_empty_projects + self.invalidate() + + def filterAcceptsRow(self, row, parent): + if not self._filter_empty_projects: + return True + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if model.data(source_index, PROJECT_NAME_ROLE) is None: + return False + return True + + +class AssetsModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(AssetsModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.started", self._on_refresh_start + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_refresh_finish + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + asset_id = item.data(ASSET_ID_ROLE) + if asset_id is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_refresh_start(self, event): + pass + + def _on_refresh_finish(self, event): + event_project_name = event["project_name"] + project_name = self._controller.selection_model.project_name + if event_project_name != project_name: + return + + self._last_project = event["project_name"] + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_items_by_id = self._controller.model.get_assets(project_name) + if not asset_items_by_id: + self._clear() + self.items_changed.emit() + return + + assets_by_parent_id = collections.defaultdict(list) + for asset_item in asset_items_by_id.values(): + assets_by_parent_id[asset_item.parent_id].append(asset_item) + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + items_to_remove = set(self._items) - set(asset_items_by_id.keys()) + hierarchy_queue = collections.deque() + hierarchy_queue.append((None, root_item)) + while hierarchy_queue: + parent_id, parent_item = hierarchy_queue.popleft() + new_items = [] + for asset_item in assets_by_parent_id[parent_id]: + item = self._items.get(asset_item.id) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[asset_item.id] = item + + elif item.parent() is not parent_item: + new_items.append(item) + + icon = get_asset_icon_by_name( + asset_item.icon_name, asset_item.icon_color + ) + item.setData(asset_item.name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(asset_item.id, ASSET_ID_ROLE) + + hierarchy_queue.append((asset_item.id, item)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + row = item.row() + if row < 0: + continue + parent = item.parent() + if parent is None: + parent = root_item + parent.takeRow(row) + + self.items_changed.emit() + + +class TasksModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(TasksModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_asset_refresh_finish + ) + self.event_system.add_callback( + "asset.changed", self._on_asset_change + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + task_name = item.data(TASK_NAME_ROLE) + if task_name is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_asset_refresh_finish(self, event): + self._refresh(event["project_name"]) + + def _on_asset_change(self, event): + self._refresh(event["project_name"]) + + def _refresh(self, new_project_name): + project_name = self._controller.selection_model.project_name + if new_project_name != project_name: + return + + self._last_project = project_name + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_id = self._controller.selection_model.asset_id + task_items = self._controller.model.get_tasks( + project_name, asset_id + ) + if not task_items: + self._clear() + self.items_changed.emit() + return + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + new_items = [] + task_names = set() + for task_item in task_items: + task_name = task_item.name + item = self._items.get(task_name) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[task_name] = item + + item.setData(task_name, QtCore.Qt.DisplayRole) + item.setData(task_name, TASK_NAME_ROLE) + item.setData(task_item.task_type, TASK_TYPE_ROLE) + + if new_items: + root_item.appendRows(new_items) + + items_to_remove = set(self._items) - task_names + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + parent = item.parent() + if parent is not None: + parent.removeRow(item.row()) + + self.items_changed.emit() + + +class PushToContextSelectWindow(QtWidgets.QWidget): + def __init__(self, controller=None): + super(PushToContextSelectWindow, self).__init__() + if controller is None: + controller = PushToContextController() + self._controller = controller + + self.setWindowTitle("Push to project (select context)") + self.setWindowIcon(QtGui.QIcon(get_app_icon_path())) + + main_context_widget = QtWidgets.QWidget(self) + + header_widget = QtWidgets.QWidget(main_context_widget) + + header_label = QtWidgets.QLabel(controller.src_label, header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(header_label) + + main_splitter = QtWidgets.QSplitter( + QtCore.Qt.Horizontal, main_context_widget + ) + + context_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(context_widget) + project_model = ProjectsModel(controller) + project_proxy = ProjectProxyModel() + project_proxy.setSourceModel(project_model) + project_proxy.setDynamicSortFilter(True) + project_delegate = QtWidgets.QStyledItemDelegate() + project_combobox.setItemDelegate(project_delegate) + project_combobox.setModel(project_proxy) + + asset_task_splitter = QtWidgets.QSplitter( + QtCore.Qt.Vertical, context_widget + ) + + asset_view = DeselectableTreeView(asset_task_splitter) + asset_view.setHeaderHidden(True) + asset_model = AssetsModel(controller) + asset_proxy = QtCore.QSortFilterProxyModel() + asset_proxy.setSourceModel(asset_model) + asset_proxy.setDynamicSortFilter(True) + asset_view.setModel(asset_proxy) + + task_view = QtWidgets.QListView(asset_task_splitter) + task_proxy = QtCore.QSortFilterProxyModel() + task_model = TasksModel(controller) + task_proxy.setSourceModel(task_model) + task_proxy.setDynamicSortFilter(True) + task_view.setModel(task_proxy) + + asset_task_splitter.addWidget(asset_view) + asset_task_splitter.addWidget(task_view) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(project_combobox, 0) + context_layout.addWidget(asset_task_splitter, 1) + + # --- Inputs widget --- + inputs_widget = QtWidgets.QWidget(main_splitter) + + asset_name_input = PlaceholderLineEdit(inputs_widget) + asset_name_input.setPlaceholderText("< Name of new asset >") + asset_name_input.setObjectName("ValidatedLineEdit") + + variant_input = PlaceholderLineEdit(inputs_widget) + variant_input.setPlaceholderText("< Variant >") + variant_input.setObjectName("ValidatedLineEdit") + + comment_input = PlaceholderLineEdit(inputs_widget) + comment_input.setPlaceholderText("< Publish comment >") + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("New asset name", asset_name_input) + inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow("Comment", comment_input) + + main_splitter.addWidget(context_widget) + main_splitter.addWidget(inputs_widget) + + # --- Buttons widget --- + btns_widget = QtWidgets.QWidget(self) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + publish_btn = QtWidgets.QPushButton("Publish", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) + btns_layout.addWidget(publish_btn, 0) + + sep_1 = SeparatorWidget(parent=main_context_widget) + sep_2 = SeparatorWidget(parent=main_context_widget) + main_context_layout = QtWidgets.QVBoxLayout(main_context_widget) + main_context_layout.addWidget(header_widget, 0) + main_context_layout.addWidget(sep_1, 0) + main_context_layout.addWidget(main_splitter, 1) + main_context_layout.addWidget(sep_2, 0) + main_context_layout.addWidget(btns_widget, 0) + + # NOTE This was added in hurry + # - should be reorganized and changed styles + overlay_widget = QtWidgets.QFrame(self) + overlay_widget.setObjectName("OverlayFrame") + + overlay_label = QtWidgets.QLabel(overlay_widget) + overlay_label.setAlignment(QtCore.Qt.AlignCenter) + + overlay_btns_widget = QtWidgets.QWidget(overlay_widget) + overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + # Add try again button (requires changes in controller) + overlay_try_btn = QtWidgets.QPushButton( + "Try again", overlay_btns_widget + ) + overlay_close_btn = QtWidgets.QPushButton( + "Close", overlay_btns_widget + ) + + overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget) + overlay_btns_layout.addStretch(1) + overlay_btns_layout.addWidget(overlay_try_btn, 0) + overlay_btns_layout.addWidget(overlay_close_btn, 0) + overlay_btns_layout.addStretch(1) + + overlay_layout = QtWidgets.QVBoxLayout(overlay_widget) + overlay_layout.addWidget(overlay_label, 0) + overlay_layout.addWidget(overlay_btns_widget, 0) + overlay_layout.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QStackedLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(main_context_widget) + main_layout.addWidget(overlay_widget) + main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + main_layout.setCurrentWidget(main_context_widget) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(10) + + user_input_changed_timer = QtCore.QTimer() + user_input_changed_timer.setInterval(200) + user_input_changed_timer.setSingleShot(True) + + main_thread_timer.timeout.connect(self._on_main_thread_timer) + show_timer.timeout.connect(self._on_show_timer) + user_input_changed_timer.timeout.connect(self._on_user_input_timer) + asset_name_input.textChanged.connect(self._on_new_asset_change) + variant_input.textChanged.connect(self._on_variant_change) + comment_input.textChanged.connect(self._on_comment_change) + project_model.refreshed.connect(self._on_projects_refresh) + project_combobox.currentIndexChanged.connect(self._on_project_change) + asset_view.selectionModel().selectionChanged.connect( + self._on_asset_change + ) + asset_model.items_changed.connect(self._on_asset_model_change) + task_view.selectionModel().selectionChanged.connect( + self._on_task_change + ) + task_model.items_changed.connect(self._on_task_model_change) + publish_btn.clicked.connect(self._on_select_click) + cancel_btn.clicked.connect(self._on_close_click) + overlay_close_btn.clicked.connect(self._on_close_click) + overlay_try_btn.clicked.connect(self._on_try_again_click) + + controller.event_system.add_callback( + "new_asset_name.changed", self._on_controller_new_asset_change + ) + controller.event_system.add_callback( + "variant.changed", self._on_controller_variant_change + ) + controller.event_system.add_callback( + "comment.changed", self._on_controller_comment_change + ) + controller.event_system.add_callback( + "submission.enabled.changed", self._on_submission_change + ) + controller.event_system.add_callback( + "source.changed", self._on_controller_source_change + ) + controller.event_system.add_callback( + "submit.started", self._on_controller_submit_start + ) + controller.event_system.add_callback( + "submit.finished", self._on_controller_submit_end + ) + controller.event_system.add_callback( + "push.message.added", self._on_push_message + ) + + self._main_layout = main_layout + + self._main_context_widget = main_context_widget + + self._header_label = header_label + self._main_splitter = main_splitter + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._asset_view = asset_view + self._asset_model = asset_model + self._asset_proxy_model = asset_proxy + + self._task_view = task_view + self._task_proxy_model = task_proxy + + self._variant_input = variant_input + self._asset_name_input = asset_name_input + self._comment_input = comment_input + + self._publish_btn = publish_btn + + self._overlay_widget = overlay_widget + self._overlay_close_btn = overlay_close_btn + self._overlay_try_btn = overlay_try_btn + self._overlay_label = overlay_label + + self._user_input_changed_timer = user_input_changed_timer + # Store current value on input text change + # The value is unset when is passed to controller + # The goal is to have controll over changes happened during user change + # in UI and controller auto-changes + self._variant_input_text = None + self._new_asset_name_input_text = None + self._comment_input_text = None + self._show_timer = show_timer + self._show_counter = 2 + self._first_show = True + + self._main_thread_timer = main_thread_timer + self._main_thread_timer_can_stop = True + self._last_submit_message = None + self._process_item = None + + publish_btn.setEnabled(False) + overlay_close_btn.setVisible(False) + overlay_try_btn.setVisible(False) + + if controller.user_values.new_asset_name: + asset_name_input.setText(controller.user_values.new_asset_name) + if controller.user_values.variant: + variant_input.setText(controller.user_values.variant) + self._invalidate_variant() + self._invalidate_new_asset_name() + + @property + def controller(self): + return self._controller + + def showEvent(self, event): + super(PushToContextSelectWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(load_stylesheet()) + self._invalidate_variant() + self._show_timer.start() + + def _on_show_timer(self): + if self._show_counter == 0: + self._show_timer.stop() + return + + self._show_counter -= 1 + if self._show_counter == 1: + width = 740 + height = 640 + inputs_width = 360 + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + + if self._show_counter > 0: + return + + self._controller.model.refresh_projects() + + def _on_new_asset_change(self, text): + self._new_asset_name_input_text = text + self._user_input_changed_timer.start() + + def _on_variant_change(self, text): + self._variant_input_text = text + self._user_input_changed_timer.start() + + def _on_comment_change(self, text): + self._comment_input_text = text + self._user_input_changed_timer.start() + + def _on_user_input_timer(self): + asset_name = self._new_asset_name_input_text + if asset_name is not None: + self._new_asset_name_input_text = None + self._controller.user_values.set_new_asset(asset_name) + + variant = self._variant_input_text + if variant is not None: + self._variant_input_text = None + self._controller.user_values.set_variant(variant) + + comment = self._comment_input_text + if comment is not None: + self._comment_input_text = None + self._controller.user_values.set_comment(comment) + + def _on_controller_new_asset_change(self, event): + asset_name = event["changes"]["new_asset_name"]["new"] + if ( + self._new_asset_name_input_text is None + and asset_name != self._asset_name_input.text() + ): + self._asset_name_input.setText(asset_name) + + self._invalidate_new_asset_name() + + def _on_controller_variant_change(self, event): + is_valid_changes = event["changes"]["is_valid"] + variant = event["changes"]["variant"]["new"] + if ( + self._variant_input_text is None + and variant != self._variant_input.text() + ): + self._variant_input.setText(variant) + + if is_valid_changes["old"] != is_valid_changes["new"]: + self._invalidate_variant() + + def _on_controller_comment_change(self, event): + comment = event["comment"] + if ( + self._comment_input_text is None + and comment != self._comment_input.text() + ): + self._comment_input.setText(comment) + + def _on_controller_source_change(self): + self._header_label.setText(self._controller.src_label) + + def _invalidate_new_asset_name(self): + asset_name = self._controller.user_values.new_asset_name + self._task_view.setVisible(not asset_name) + + valid = None + if asset_name: + valid = self._controller.user_values.is_new_asset_name_valid + + state = "" + if valid is True: + state = "valid" + elif valid is False: + state = "invalid" + set_style_property(self._asset_name_input, "state", state) + + def _invalidate_variant(self): + valid = self._controller.user_values.is_variant_valid + state = "invalid" + if valid is True: + state = "valid" + set_style_property(self._variant_input, "state", state) + + def _on_projects_refresh(self): + self._project_proxy.sort(0, QtCore.Qt.AscendingOrder) + + def _on_project_change(self): + idx = self._project_combobox.currentIndex() + if idx < 0: + self._project_proxy.set_filter_empty_project(False) + return + + project_name = self._project_combobox.itemData(idx, PROJECT_NAME_ROLE) + self._project_proxy.set_filter_empty_project(project_name is not None) + self._controller.selection_model.select_project(project_name) + + def _on_asset_change(self): + indexes = self._asset_view.selectedIndexes() + index = next(iter(indexes), None) + asset_id = None + if index is not None: + model = self._asset_view.model() + asset_id = model.data(index, ASSET_ID_ROLE) + self._controller.selection_model.select_asset(asset_id) + + def _on_asset_model_change(self): + self._asset_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_model_change(self): + self._task_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_change(self): + indexes = self._task_view.selectedIndexes() + index = next(iter(indexes), None) + task_name = None + if index is not None: + model = self._task_view.model() + task_name = model.data(index, TASK_NAME_ROLE) + self._controller.selection_model.select_task(task_name) + + def _on_submission_change(self, event): + self._publish_btn.setEnabled(event["enabled"]) + + def _on_close_click(self): + self.close() + + def _on_select_click(self): + self._process_item = self._controller.submit(wait=False) + + def _on_try_again_click(self): + self._process_item = None + self._last_submit_message = None + + self._overlay_close_btn.setVisible(False) + self._overlay_try_btn.setVisible(False) + self._main_layout.setCurrentWidget(self._main_context_widget) + + def _on_main_thread_timer(self): + if self._last_submit_message: + self._overlay_label.setText(self._last_submit_message) + self._last_submit_message = None + + process_status = self._process_item.status + push_failed = process_status.failed + fail_traceback = process_status.traceback + if self._main_thread_timer_can_stop: + self._main_thread_timer.stop() + self._overlay_close_btn.setVisible(True) + if push_failed and not fail_traceback: + self._overlay_try_btn.setVisible(True) + + if push_failed: + message = "Push Failed:\n{}".format(process_status.fail_reason) + if fail_traceback: + message += "\n{}".format(fail_traceback) + self._overlay_label.setText(message) + set_style_property(self._overlay_close_btn, "state", "error") + + if self._main_thread_timer_can_stop: + # Join thread in controller + self._controller.wait_for_process_thread() + # Reset process item to None + self._process_item = None + + def _on_controller_submit_start(self): + self._main_thread_timer_can_stop = False + self._main_thread_timer.start() + self._main_layout.setCurrentWidget(self._overlay_widget) + self._overlay_label.setText("Submittion started") + + def _on_controller_submit_end(self): + self._main_thread_timer_can_stop = True + + def _on_push_message(self, event): + self._last_submit_message = event["message"] From 065ebc389c3d8377903f814b0c76d5cc15b4429a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 15:35:56 +0200 Subject: [PATCH 0711/1224] renamed 'app.py' to 'main.py' and use it in loader action --- openpype/plugins/load/push_to_library.py | 24 +++++++++++++------ .../ayon_push_to_project/{app.py => main.py} | 0 2 files changed, 17 insertions(+), 7 deletions(-) rename openpype/tools/ayon_push_to_project/{app.py => main.py} (100%) diff --git a/openpype/plugins/load/push_to_library.py b/openpype/plugins/load/push_to_library.py index dd7291e686..5befc5eb9d 100644 --- a/openpype/plugins/load/push_to_library.py +++ b/openpype/plugins/load/push_to_library.py @@ -1,6 +1,6 @@ import os -from openpype import PACKAGE_DIR +from openpype import PACKAGE_DIR, AYON_SERVER_ENABLED from openpype.lib import get_openpype_execute_args, run_detached_process from openpype.pipeline import load from openpype.pipeline.load import LoadError @@ -32,12 +32,22 @@ class PushToLibraryProject(load.SubsetLoaderPlugin): raise LoadError("Please select only one item") context = tuple(filtered_contexts)[0] - push_tool_script_path = os.path.join( - PACKAGE_DIR, - "tools", - "push_to_project", - "app.py" - ) + + if AYON_SERVER_ENABLED: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "ayon_push_to_project", + "main.py" + ) + else: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "push_to_project", + "app.py" + ) + project_doc = context["project"] version_doc = context["version"] project_name = project_doc["name"] diff --git a/openpype/tools/ayon_push_to_project/app.py b/openpype/tools/ayon_push_to_project/main.py similarity index 100% rename from openpype/tools/ayon_push_to_project/app.py rename to openpype/tools/ayon_push_to_project/main.py From 178ab5d77a2e9f34ee5766e0d8a7d1bcd0cae8da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:08:16 +0200 Subject: [PATCH 0712/1224] moved 'window.py' to subfolder 'ui' --- openpype/tools/ayon_push_to_project/main.py | 20 +++++++++++-------- .../tools/ayon_push_to_project/ui/__init__.py | 6 ++++++ .../ayon_push_to_project/{ => ui}/window.py | 0 3 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/ui/__init__.py rename openpype/tools/ayon_push_to_project/{ => ui}/window.py (100%) diff --git a/openpype/tools/ayon_push_to_project/main.py b/openpype/tools/ayon_push_to_project/main.py index b3ec33f353..e36940e488 100644 --- a/openpype/tools/ayon_push_to_project/main.py +++ b/openpype/tools/ayon_push_to_project/main.py @@ -1,7 +1,17 @@ import click from openpype.tools.utils import get_openpype_qt_app -from openpype.tools.push_to_project.window import PushToContextSelectWindow +from openpype.tools.ayon_push_to_project.ui import PushToContextSelectWindow + + +def main_show(project_name, version_id): + app = get_openpype_qt_app() + + window = PushToContextSelectWindow() + window.show() + window.set_source(project_name, version_id) + + app.exec_() @click.command() @@ -15,13 +25,7 @@ def main(project, version): version (str): Version id. """ - app = get_openpype_qt_app() - - window = PushToContextSelectWindow() - window.show() - window.controller.set_source(project, version) - - app.exec_() + main_show(project, version) if __name__ == "__main__": diff --git a/openpype/tools/ayon_push_to_project/ui/__init__.py b/openpype/tools/ayon_push_to_project/ui/__init__.py new file mode 100644 index 0000000000..1e86475530 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import PushToContextSelectWindow + + +__all__ = ( + "PushToContextSelectWindow", +) diff --git a/openpype/tools/ayon_push_to_project/window.py b/openpype/tools/ayon_push_to_project/ui/window.py similarity index 100% rename from openpype/tools/ayon_push_to_project/window.py rename to openpype/tools/ayon_push_to_project/ui/window.py From 4481c7590b1c66fa9f36c08311adfc246f4bed6e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:11:06 +0200 Subject: [PATCH 0713/1224] renamed 'control_context.py' to 'control.py' --- openpype/tools/ayon_push_to_project/__init__.py | 6 ++++++ .../ayon_push_to_project/{control_context.py => control.py} | 0 openpype/tools/ayon_push_to_project/ui/window.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) rename openpype/tools/ayon_push_to_project/{control_context.py => control.py} (100%) diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py index e69de29bb2..83df110c96 100644 --- a/openpype/tools/ayon_push_to_project/__init__.py +++ b/openpype/tools/ayon_push_to_project/__init__.py @@ -0,0 +1,6 @@ +from .control import PushToContextController + + +__all__ = ( + "PushToContextController", +) diff --git a/openpype/tools/ayon_push_to_project/control_context.py b/openpype/tools/ayon_push_to_project/control.py similarity index 100% rename from openpype/tools/ayon_push_to_project/control_context.py rename to openpype/tools/ayon_push_to_project/control.py diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index dc5eab5787..a1fff2d27d 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -11,7 +11,7 @@ from openpype.tools.utils import ( ) from openpype.tools.utils.views import DeselectableTreeView -from .control_context import PushToContextController +from openpype.tools.ayon_push_to_project import PushToContextController PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 From edf5c525415961fa079e3e7d2772cd0fb06a87f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:12:00 +0200 Subject: [PATCH 0714/1224] initial modification of controller --- .../tools/ayon_push_to_project/control.py | 662 ++++++------------ .../ayon_push_to_project/models/__init__.py | 6 + .../ayon_push_to_project/models/selection.py | 72 ++ 3 files changed, 288 insertions(+), 452 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/models/__init__.py create mode 100644 openpype/tools/ayon_push_to_project/models/selection.py diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index e4058893d5..4aef09156f 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -1,10 +1,7 @@ import re -import collections import threading from openpype.client import ( - get_projects, - get_assets, get_asset_by_id, get_subset_by_id, get_version_by_id, @@ -12,260 +9,47 @@ from openpype.client import ( ) from openpype.settings import get_project_settings from openpype.lib import prepare_template_data -from openpype.lib.events import EventSystem +from openpype.lib.events import QueuedEventSystem from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, get_subset_name_template, ) +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .control_integrate import ( ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, ) - - -class AssetItem: - def __init__( - self, - entity_id, - name, - icon_name, - icon_color, - parent_id, - has_children - ): - self.id = entity_id - self.name = name - self.icon_name = icon_name - self.icon_color = icon_color - self.parent_id = parent_id - self.has_children = has_children - - @classmethod - def from_doc(cls, asset_doc, has_children=True): - parent_id = asset_doc["data"].get("visualParent") - if parent_id is not None: - parent_id = str(parent_id) - return cls( - str(asset_doc["_id"]), - asset_doc["name"], - asset_doc["data"].get("icon"), - asset_doc["data"].get("color"), - parent_id, - has_children - ) - - -class TaskItem: - def __init__(self, asset_id, name, task_type, short_name): - self.asset_id = asset_id - self.name = name - self.task_type = task_type - self.short_name = short_name - - @classmethod - def from_asset_doc(cls, asset_doc, project_doc): - asset_tasks = asset_doc["data"].get("tasks") or {} - project_task_types = project_doc["config"]["tasks"] - output = [] - for task_name, task_info in asset_tasks.items(): - task_type = task_info.get("type") - task_type_info = project_task_types.get(task_type) or {} - output.append(cls( - asset_doc["_id"], - task_name, - task_type, - task_type_info.get("short_name") - )) - return output - - -class EntitiesModel: - def __init__(self, event_system): - self._event_system = event_system - self._project_names = None - self._project_docs_by_name = {} - self._assets_by_project = {} - self._tasks_by_asset_id = collections.defaultdict(dict) - - def has_cached_projects(self): - return self._project_names is None - - def has_cached_assets(self, project_name): - if not project_name: - return True - return project_name in self._assets_by_project - - def has_cached_tasks(self, project_name): - return self.has_cached_assets(project_name) - - def get_projects(self): - if self._project_names is None: - self.refresh_projects() - return list(self._project_names) - - def get_assets(self, project_name): - if project_name not in self._assets_by_project: - self.refresh_assets(project_name) - return dict(self._assets_by_project[project_name]) - - def get_asset_by_id(self, project_name, asset_id): - return self._assets_by_project[project_name].get(asset_id) - - def get_tasks(self, project_name, asset_id): - if not project_name or not asset_id: - return [] - - if project_name not in self._tasks_by_asset_id: - self.refresh_assets(project_name) - - all_task_items = self._tasks_by_asset_id[project_name] - asset_task_items = all_task_items.get(asset_id) - if not asset_task_items: - return [] - return list(asset_task_items) - - def refresh_projects(self, force=False): - self._event_system.emit( - "projects.refresh.started", {}, "entities.model" - ) - if force or self._project_names is None: - project_names = [] - project_docs_by_name = {} - for project_doc in get_projects(): - library_project = project_doc["data"].get("library_project") - if not library_project: - continue - project_name = project_doc["name"] - project_names.append(project_name) - project_docs_by_name[project_name] = project_doc - self._project_names = project_names - self._project_docs_by_name = project_docs_by_name - self._event_system.emit( - "projects.refresh.finished", {}, "entities.model" - ) - - def _refresh_assets(self, project_name): - asset_items_by_id = {} - task_items_by_asset_id = {} - self._assets_by_project[project_name] = asset_items_by_id - self._tasks_by_asset_id[project_name] = task_items_by_asset_id - if not project_name: - return - - project_doc = self._project_docs_by_name[project_name] - asset_docs_by_parent_id = collections.defaultdict(list) - for asset_doc in get_assets(project_name): - parent_id = asset_doc["data"].get("visualParent") - asset_docs_by_parent_id[parent_id].append(asset_doc) - - hierarchy_queue = collections.deque() - for asset_doc in asset_docs_by_parent_id[None]: - hierarchy_queue.append(asset_doc) - - while hierarchy_queue: - asset_doc = hierarchy_queue.popleft() - children = asset_docs_by_parent_id[asset_doc["_id"]] - asset_item = AssetItem.from_doc(asset_doc, len(children) > 0) - asset_items_by_id[asset_item.id] = asset_item - task_items_by_asset_id[asset_item.id] = ( - TaskItem.from_asset_doc(asset_doc, project_doc) - ) - for child in children: - hierarchy_queue.append(child) - - def refresh_assets(self, project_name, force=False): - self._event_system.emit( - "assets.refresh.started", - {"project_name": project_name}, - "entities.model" - ) - - if force or project_name not in self._assets_by_project: - self._refresh_assets(project_name) - - self._event_system.emit( - "assets.refresh.finished", - {"project_name": project_name}, - "entities.model" - ) - - -class SelectionModel: - def __init__(self, event_system): - self._event_system = event_system - - self.project_name = None - self.asset_id = None - self.task_name = None - - def select_project(self, project_name): - if self.project_name == project_name: - return - - self.project_name = project_name - self._event_system.emit( - "project.changed", - {"project_name": project_name}, - "selection.model" - ) - - def select_asset(self, asset_id): - if self.asset_id == asset_id: - return - self.asset_id = asset_id - self._event_system.emit( - "asset.changed", - { - "project_name": self.project_name, - "asset_id": asset_id - }, - "selection.model" - ) - - def select_task(self, task_name): - if self.task_name == task_name: - return - self.task_name = task_name - self._event_system.emit( - "task.changed", - { - "project_name": self.project_name, - "asset_id": self.asset_id, - "task_name": task_name - }, - "selection.model" - ) +from .models import PushToProjectSelectionModel class UserPublishValues: """Helper object to validate values required for push to different project. Args: - event_system (EventSystem): Event system to catch and emit events. - new_asset_name (str): Name of new asset name. - variant (str): Variant for new subset name in new project. + controller (PushToContextController): Event system to catch + and emit events. """ - asset_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) - def __init__(self, event_system): - self._event_system = event_system - self._new_asset_name = None + def __init__(self, controller): + self._controller = controller + self._new_folder_name = None self._variant = None self._comment = None self._is_variant_valid = False - self._is_new_asset_name_valid = False + self._is_new_folder_name_valid = False - self.set_new_asset("") + self.set_new_folder_name("") self.set_variant("") self.set_comment("") @property - def new_asset_name(self): - return self._new_asset_name + def new_folder_name(self): + return self._new_folder_name @property def variant(self): @@ -280,70 +64,58 @@ class UserPublishValues: return self._is_variant_valid @property - def is_new_asset_name_valid(self): - return self._is_new_asset_name_valid + def is_new_folder_name_valid(self): + return self._is_new_folder_name_valid @property def is_valid(self): - return self.is_variant_valid and self.is_new_asset_name_valid + return self.is_variant_valid and self.is_new_folder_name_valid + + def get_data(self): + return { + "new_folder_name": self._new_folder_name, + "variant": self._variant, + "comment": self._comment, + "is_variant_valid": self._is_variant_valid, + "is_new_folder_name_valid": self._is_new_folder_name_valid, + "is_valid": self.is_valid + } def set_variant(self, variant): if variant == self._variant: return - old_variant = self._variant - old_is_valid = self._is_variant_valid - self._variant = variant is_valid = False if variant: is_valid = self.variant_regex.match(variant) is not None self._is_variant_valid = is_valid - changes = { - key: {"new": new, "old": old} - for key, old, new in ( - ("variant", old_variant, variant), - ("is_valid", old_is_valid, is_valid) - ) - } - - self._event_system.emit( + self._controller.emit_event( "variant.changed", { "variant": variant, "is_valid": self._is_variant_valid, - "changes": changes }, "user_values" ) - def set_new_asset(self, asset_name): - if self._new_asset_name == asset_name: + def set_new_folder_name(self, folder_name): + if self._new_folder_name == folder_name: return - old_asset_name = self._new_asset_name - old_is_valid = self._is_new_asset_name_valid - self._new_asset_name = asset_name - is_valid = True - if asset_name: - is_valid = ( - self.asset_name_regex.match(asset_name) is not None - ) - self._is_new_asset_name_valid = is_valid - changes = { - key: {"new": new, "old": old} - for key, old, new in ( - ("new_asset_name", old_asset_name, asset_name), - ("is_valid", old_is_valid, is_valid) - ) - } - self._event_system.emit( - "new_asset_name.changed", + self._new_folder_name = folder_name + is_valid = True + if folder_name: + is_valid = ( + self.folder_name_regex.match(folder_name) is not None + ) + self._is_new_folder_name_valid = is_valid + self._controller.emit_event( + "new_folder_name.changed", { - "new_asset_name": self._new_asset_name, - "is_valid": self._is_new_asset_name_valid, - "changes": changes + "new_folder_name": self._new_folder_name, + "is_valid": self._is_new_folder_name_valid, }, "user_values" ) @@ -351,42 +123,30 @@ class UserPublishValues: def set_comment(self, comment): if comment == self._comment: return - old_comment = self._comment self._comment = comment - self._event_system.emit( + self._controller.emit_event( "comment.changed", - { - "comment": comment, - "changes": { - "comment": {"new": comment, "old": old_comment} - } - }, + {"comment": comment}, "user_values" ) class PushToContextController: def __init__(self, project_name=None, version_id=None): + self._event_system = self._create_event_system() + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + + self._selection_model = PushToProjectSelectionModel(self) + self._user_values = UserPublishValues(self) + self._src_project_name = None self._src_version_id = None self._src_asset_doc = None self._src_subset_doc = None self._src_version_doc = None - - event_system = EventSystem() - entities_model = EntitiesModel(event_system) - selection_model = SelectionModel(event_system) - user_values = UserPublishValues(event_system) - - self._event_system = event_system - self._entities_model = entities_model - self._selection_model = selection_model - self._user_values = user_values - - event_system.add_callback("project.changed", self._on_project_change) - event_system.add_callback("asset.changed", self._invalidate) - event_system.add_callback("variant.changed", self._invalidate) - event_system.add_callback("new_asset_name.changed", self._invalidate) + self._src_label = None self._submission_enabled = False self._process_thread = None @@ -394,6 +154,157 @@ class PushToContextController: self.set_source(project_name, version_id) + # 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 set_source(self, project_name, version_id): + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + self._src_label = None + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self._user_values.set_new_folder_name(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self._user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self._user_values.set_comment(comment) + + self._event_system.emit( + "source.changed", { + "project_name": project_name, + "version_id": version_id + }, + "controller" + ) + + def get_source_label(self): + if self._src_label is None: + self._src_label = self._get_source_label() + return self._src_label + + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + def get_user_values(self): + return self._user_values.get_data() + + def set_user_value_folder_name(self, folder_name): + self._user_values.set_new_folder_name(folder_name) + + def set_user_value_variant(self, variant): + self._user_values.set_variant(variant) + + def set_user_value_comment(self, comment): + self._user_values.set_comment(comment) + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + # Processing methods + def submit(self, wait=True): + if not self._submission_enabled: + return + + if self._process_thread is not None: + return + + item = ProjectPushItem( + self._src_project_name, + self._src_version_id, + self._selection_model.get_selected_project_name(), + self._selection_model.get_selected_folder_id(), + self._selection_model.get_selected_task_name(), + self._user_values.variant, + comment=self._user_values.comment, + new_folder_name=self._user_values.new_folder_name, + dst_version=1 + ) + + status_item = ProjectPushItemStatus(event_system=self._event_system) + process_item = ProjectPushItemProcess(item, status_item) + self._process_item = process_item + self._event_system.emit("submit.started", {}, "controller") + if wait: + self._submit_callback() + self._process_item = None + return process_item + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return process_item + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _get_source_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self._src_asset_doc + if not asset_doc: + return "Source is invalid" + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + subset_doc = self._src_subset_doc + version_doc = self._src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + asset_path, + subset_doc["name"], + version_doc["name"] + ) + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): asset_tasks = asset_doc["data"].get("tasks") or {} found_comb = [] @@ -436,7 +347,7 @@ class PushToContextController: ) project_settings = get_project_settings(project_name) - subset_doc = self.src_subset_doc + subset_doc = self._src_subset_doc family = subset_doc["data"].get("family") if not family: family = subset_doc["data"]["families"][0] @@ -470,7 +381,7 @@ class PushToContextController: print("Failed format", exc) return "" - subset_name = self.src_subset_doc["name"] + subset_name = self._src_subset_doc["name"] if ( (subset_s and not subset_name.startswith(subset_s)) or (subset_e and not subset_name.endswith(subset_e)) @@ -483,112 +394,7 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def set_source(self, project_name, version_id): - if ( - project_name == self._src_project_name - and version_id == self._src_version_id - ): - return - - self._src_project_name = project_name - self._src_version_id = version_id - asset_doc = None - subset_doc = None - version_doc = None - if project_name and version_id: - version_doc = get_version_by_id(project_name, version_id) - - if version_doc: - subset_doc = get_subset_by_id(project_name, version_doc["parent"]) - - if subset_doc: - asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) - - self._src_asset_doc = asset_doc - self._src_subset_doc = subset_doc - self._src_version_doc = version_doc - if asset_doc: - self.user_values.set_new_asset(asset_doc["name"]) - variant = self._get_src_variant() - if variant: - self.user_values.set_variant(variant) - - comment = version_doc["data"].get("comment") - if comment: - self.user_values.set_comment(comment) - - self._event_system.emit( - "source.changed", { - "project_name": project_name, - "version_id": version_id - }, - "controller" - ) - - @property - def src_project_name(self): - return self._src_project_name - - @property - def src_version_id(self): - return self._src_version_id - - @property - def src_label(self): - if not self._src_project_name or not self._src_version_id: - return "Source is not defined" - - asset_doc = self.src_asset_doc - if not asset_doc: - return "Source is invalid" - - asset_path_parts = list(asset_doc["data"]["parents"]) - asset_path_parts.append(asset_doc["name"]) - asset_path = "/".join(asset_path_parts) - subset_doc = self.src_subset_doc - version_doc = self.src_version_doc - return "Source: {}/{}/{}/v{:0>3}".format( - self._src_project_name, - asset_path, - subset_doc["name"], - version_doc["name"] - ) - - @property - def src_version_doc(self): - return self._src_version_doc - - @property - def src_subset_doc(self): - return self._src_subset_doc - - @property - def src_asset_doc(self): - return self._src_asset_doc - - @property - def event_system(self): - return self._event_system - - @property - def model(self): - return self._entities_model - - @property - def selection_model(self): - return self._selection_model - - @property - def user_values(self): - return self._user_values - - @property - def submission_enabled(self): - return self._submission_enabled - def _on_project_change(self, event): - project_name = event["project_name"] - self.model.refresh_assets(project_name) self._invalidate() def _invalidate(self): @@ -606,68 +412,17 @@ class PushToContextController: if not self._user_values.is_valid: return False - if not self.selection_model.project_name: + if not self._selection_model.get_selected_project_name(): return False if ( - not self._user_values.new_asset_name - and not self.selection_model.asset_id + not self._user_values.new_folder_name + and not self._selection_model.get_selected_folder_id() ): return False return True - def get_selected_asset_name(self): - project_name = self._selection_model.project_name - asset_id = self._selection_model.asset_id - if not project_name or not asset_id: - return None - asset_item = self._entities_model.get_asset_by_id( - project_name, asset_id - ) - if asset_item: - return asset_item.name - return None - - def submit(self, wait=True): - if not self.submission_enabled: - return - - if self._process_thread is not None: - return - - item = ProjectPushItem( - self.src_project_name, - self.src_version_id, - self.selection_model.project_name, - self.selection_model.asset_id, - self.selection_model.task_name, - self.user_values.variant, - comment=self.user_values.comment, - new_asset_name=self.user_values.new_asset_name, - dst_version=1 - ) - - status_item = ProjectPushItemStatus(event_system=self._event_system) - process_item = ProjectPushItemProcess(item, status_item) - self._process_item = process_item - self._event_system.emit("submit.started", {}, "controller") - if wait: - self._submit_callback() - self._process_item = None - return process_item - - thread = threading.Thread(target=self._submit_callback) - self._process_thread = thread - thread.start() - return process_item - - def wait_for_process_thread(self): - if self._process_thread is None: - return - self._process_thread.join() - self._process_thread = None - def _submit_callback(self): process_item = self._process_item if process_item is None: @@ -676,3 +431,6 @@ class PushToContextController: self._event_system.emit("submit.finished", {}, "controller") if process_item is self._process_item: self._process_item = None + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py new file mode 100644 index 0000000000..0123fc9355 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -0,0 +1,6 @@ +from .selection import PushToProjectSelectionModel + + +__all__ = ( + "PushToProjectSelectionModel", +) diff --git a/openpype/tools/ayon_push_to_project/models/selection.py b/openpype/tools/ayon_push_to_project/models/selection.py new file mode 100644 index 0000000000..19f1c6d37d --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/selection.py @@ -0,0 +1,72 @@ +class PushToProjectSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "push-to-project.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) From 78eebdaca177543235dafbda26763365678532d3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:12:21 +0200 Subject: [PATCH 0715/1224] initial changes of window --- .../tools/ayon_push_to_project/ui/window.py | 650 ++++-------------- 1 file changed, 126 insertions(+), 524 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index a1fff2d27d..d5b2823490 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -1,369 +1,19 @@ -import collections - from qtpy import QtWidgets, QtGui, QtCore from openpype.style import load_stylesheet, get_app_icon_path from openpype.tools.utils import ( PlaceholderLineEdit, SeparatorWidget, - get_asset_icon_by_name, set_style_property, ) -from openpype.tools.utils.views import DeselectableTreeView - -from openpype.tools.ayon_push_to_project import PushToContextController - -PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 -ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 -ASSET_ID_ROLE = QtCore.Qt.UserRole + 3 -TASK_NAME_ROLE = QtCore.Qt.UserRole + 4 -TASK_TYPE_ROLE = QtCore.Qt.UserRole + 5 - - -class ProjectsModel(QtGui.QStandardItemModel): - empty_text = "< Empty >" - select_project_text = "< Select Project >" - - refreshed = QtCore.Signal() - - def __init__(self, controller): - super(ProjectsModel, self).__init__() - self._controller = controller - - self.event_system.add_callback( - "projects.refresh.finished", self._on_refresh_finish - ) - - placeholder_item = QtGui.QStandardItem(self.empty_text) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._items = items - - @property - def event_system(self): - return self._controller.event_system - - def _on_refresh_finish(self): - root_item = self.invisibleRootItem() - project_names = self._controller.model.get_projects() - - if not project_names: - placeholder_text = self.empty_text - else: - placeholder_text = self.select_project_text - self._placeholder_item.setData(placeholder_text, QtCore.Qt.DisplayRole) - - new_items = [] - if None not in self._items: - new_items.append(self._placeholder_item) - - current_project_names = set(self._items.keys()) - for project_name in current_project_names - set(project_names): - if project_name is None: - continue - item = self._items.pop(project_name) - root_item.takeRow(item.row()) - - for project_name in project_names: - if project_name in self._items: - continue - item = QtGui.QStandardItem(project_name) - item.setData(project_name, PROJECT_NAME_ROLE) - new_items.append(item) - - if new_items: - root_item.appendRows(new_items) - self.refreshed.emit() - - -class ProjectProxyModel(QtCore.QSortFilterProxyModel): - def __init__(self): - super(ProjectProxyModel, self).__init__() - self._filter_empty_projects = False - - def set_filter_empty_project(self, filter_empty_projects): - if filter_empty_projects == self._filter_empty_projects: - return - self._filter_empty_projects = filter_empty_projects - self.invalidate() - - def filterAcceptsRow(self, row, parent): - if not self._filter_empty_projects: - return True - model = self.sourceModel() - source_index = model.index(row, self.filterKeyColumn(), parent) - if model.data(source_index, PROJECT_NAME_ROLE) is None: - return False - return True - - -class AssetsModel(QtGui.QStandardItemModel): - items_changed = QtCore.Signal() - empty_text = "< Empty >" - - def __init__(self, controller): - super(AssetsModel, self).__init__() - self._controller = controller - - placeholder_item = QtGui.QStandardItem(self.empty_text) - placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - - self.event_system.add_callback( - "project.changed", self._on_project_change - ) - self.event_system.add_callback( - "assets.refresh.started", self._on_refresh_start - ) - self.event_system.add_callback( - "assets.refresh.finished", self._on_refresh_finish - ) - - self._items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._last_project = None - - @property - def event_system(self): - return self._controller.event_system - - def _clear(self): - placeholder_in = False - root_item = self.invisibleRootItem() - for row in reversed(range(root_item.rowCount())): - item = root_item.child(row) - asset_id = item.data(ASSET_ID_ROLE) - if asset_id is None: - placeholder_in = True - continue - root_item.removeRow(item.row()) - - for key in tuple(self._items.keys()): - if key is not None: - self._items.pop(key) - - if not placeholder_in: - root_item.appendRows([self._placeholder_item]) - self._items[None] = self._placeholder_item - - def _on_project_change(self, event): - project_name = event["project_name"] - if project_name == self._last_project: - return - - self._last_project = project_name - self._clear() - self.items_changed.emit() - - def _on_refresh_start(self, event): - pass - - def _on_refresh_finish(self, event): - event_project_name = event["project_name"] - project_name = self._controller.selection_model.project_name - if event_project_name != project_name: - return - - self._last_project = event["project_name"] - if project_name is None: - if None not in self._items: - self._clear() - self.items_changed.emit() - return - - asset_items_by_id = self._controller.model.get_assets(project_name) - if not asset_items_by_id: - self._clear() - self.items_changed.emit() - return - - assets_by_parent_id = collections.defaultdict(list) - for asset_item in asset_items_by_id.values(): - assets_by_parent_id[asset_item.parent_id].append(asset_item) - - root_item = self.invisibleRootItem() - if None in self._items: - self._items.pop(None) - root_item.takeRow(self._placeholder_item.row()) - - items_to_remove = set(self._items) - set(asset_items_by_id.keys()) - hierarchy_queue = collections.deque() - hierarchy_queue.append((None, root_item)) - while hierarchy_queue: - parent_id, parent_item = hierarchy_queue.popleft() - new_items = [] - for asset_item in assets_by_parent_id[parent_id]: - item = self._items.get(asset_item.id) - if item is None: - item = QtGui.QStandardItem() - item.setFlags( - QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsEnabled - ) - new_items.append(item) - self._items[asset_item.id] = item - - elif item.parent() is not parent_item: - new_items.append(item) - - icon = get_asset_icon_by_name( - asset_item.icon_name, asset_item.icon_color - ) - item.setData(asset_item.name, QtCore.Qt.DisplayRole) - item.setData(icon, QtCore.Qt.DecorationRole) - item.setData(asset_item.id, ASSET_ID_ROLE) - - hierarchy_queue.append((asset_item.id, item)) - - if new_items: - parent_item.appendRows(new_items) - - for item_id in items_to_remove: - item = self._items.pop(item_id, None) - if item is None: - continue - row = item.row() - if row < 0: - continue - parent = item.parent() - if parent is None: - parent = root_item - parent.takeRow(row) - - self.items_changed.emit() - - -class TasksModel(QtGui.QStandardItemModel): - items_changed = QtCore.Signal() - empty_text = "< Empty >" - - def __init__(self, controller): - super(TasksModel, self).__init__() - self._controller = controller - - placeholder_item = QtGui.QStandardItem(self.empty_text) - placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - - self.event_system.add_callback( - "project.changed", self._on_project_change - ) - self.event_system.add_callback( - "assets.refresh.finished", self._on_asset_refresh_finish - ) - self.event_system.add_callback( - "asset.changed", self._on_asset_change - ) - - self._items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._last_project = None - - @property - def event_system(self): - return self._controller.event_system - - def _clear(self): - placeholder_in = False - root_item = self.invisibleRootItem() - for row in reversed(range(root_item.rowCount())): - item = root_item.child(row) - task_name = item.data(TASK_NAME_ROLE) - if task_name is None: - placeholder_in = True - continue - root_item.removeRow(item.row()) - - for key in tuple(self._items.keys()): - if key is not None: - self._items.pop(key) - - if not placeholder_in: - root_item.appendRows([self._placeholder_item]) - self._items[None] = self._placeholder_item - - def _on_project_change(self, event): - project_name = event["project_name"] - if project_name == self._last_project: - return - - self._last_project = project_name - self._clear() - self.items_changed.emit() - - def _on_asset_refresh_finish(self, event): - self._refresh(event["project_name"]) - - def _on_asset_change(self, event): - self._refresh(event["project_name"]) - - def _refresh(self, new_project_name): - project_name = self._controller.selection_model.project_name - if new_project_name != project_name: - return - - self._last_project = project_name - if project_name is None: - if None not in self._items: - self._clear() - self.items_changed.emit() - return - - asset_id = self._controller.selection_model.asset_id - task_items = self._controller.model.get_tasks( - project_name, asset_id - ) - if not task_items: - self._clear() - self.items_changed.emit() - return - - root_item = self.invisibleRootItem() - if None in self._items: - self._items.pop(None) - root_item.takeRow(self._placeholder_item.row()) - - new_items = [] - task_names = set() - for task_item in task_items: - task_name = task_item.name - item = self._items.get(task_name) - if item is None: - item = QtGui.QStandardItem() - item.setFlags( - QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsEnabled - ) - new_items.append(item) - self._items[task_name] = item - - item.setData(task_name, QtCore.Qt.DisplayRole) - item.setData(task_name, TASK_NAME_ROLE) - item.setData(task_item.task_type, TASK_TYPE_ROLE) - - if new_items: - root_item.appendRows(new_items) - - items_to_remove = set(self._items) - task_names - for item_id in items_to_remove: - item = self._items.pop(item_id, None) - if item is None: - continue - parent = item.parent() - if parent is not None: - parent.removeRow(item.row()) - - self.items_changed.emit() +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.ayon_push_to_project.control import ( + PushToContextController, +) class PushToContextSelectWindow(QtWidgets.QWidget): @@ -380,7 +30,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_widget = QtWidgets.QWidget(main_context_widget) - header_label = QtWidgets.QLabel(controller.src_label, header_widget) + header_label = QtWidgets.QLabel( + controller.get_source_label(), + header_widget + ) header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) @@ -392,48 +45,32 @@ class PushToContextSelectWindow(QtWidgets.QWidget): context_widget = QtWidgets.QWidget(main_splitter) - project_combobox = QtWidgets.QComboBox(context_widget) - project_model = ProjectsModel(controller) - project_proxy = ProjectProxyModel() - project_proxy.setSourceModel(project_model) - project_proxy.setDynamicSortFilter(True) - project_delegate = QtWidgets.QStyledItemDelegate() - project_combobox.setItemDelegate(project_delegate) - project_combobox.setModel(project_proxy) + projects_combobox = ProjectsCombobox(controller, context_widget) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_standard_filter_enabled(True) - asset_task_splitter = QtWidgets.QSplitter( + context_splitter = QtWidgets.QSplitter( QtCore.Qt.Vertical, context_widget ) - asset_view = DeselectableTreeView(asset_task_splitter) - asset_view.setHeaderHidden(True) - asset_model = AssetsModel(controller) - asset_proxy = QtCore.QSortFilterProxyModel() - asset_proxy.setSourceModel(asset_model) - asset_proxy.setDynamicSortFilter(True) - asset_view.setModel(asset_proxy) + folders_widget = FoldersWidget(controller, context_splitter) + folders_widget.set_deselectable(True) + tasks_widget = TasksWidget(controller, context_splitter) - task_view = QtWidgets.QListView(asset_task_splitter) - task_proxy = QtCore.QSortFilterProxyModel() - task_model = TasksModel(controller) - task_proxy.setSourceModel(task_model) - task_proxy.setDynamicSortFilter(True) - task_view.setModel(task_proxy) - - asset_task_splitter.addWidget(asset_view) - asset_task_splitter.addWidget(task_view) + context_splitter.addWidget(folders_widget) + context_splitter.addWidget(tasks_widget) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) - context_layout.addWidget(project_combobox, 0) - context_layout.addWidget(asset_task_splitter, 1) + context_layout.addWidget(projects_combobox, 0) + context_layout.addWidget(context_splitter, 1) # --- Inputs widget --- inputs_widget = QtWidgets.QWidget(main_splitter) - asset_name_input = PlaceholderLineEdit(inputs_widget) - asset_name_input.setPlaceholderText("< Name of new asset >") - asset_name_input.setObjectName("ValidatedLineEdit") + folder_name_input = PlaceholderLineEdit(inputs_widget) + folder_name_input.setPlaceholderText("< Name of new folder >") + folder_name_input.setObjectName("ValidatedLineEdit") variant_input = PlaceholderLineEdit(inputs_widget) variant_input.setPlaceholderText("< Variant >") @@ -444,7 +81,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout = QtWidgets.QFormLayout(inputs_widget) inputs_layout.setContentsMargins(0, 0, 0, 0) - inputs_layout.addRow("New asset name", asset_name_input) + inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow("Comment", comment_input) @@ -509,7 +146,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): main_layout.setCurrentWidget(main_context_widget) show_timer = QtCore.QTimer() - show_timer.setInterval(1) + show_timer.setInterval(0) main_thread_timer = QtCore.QTimer() main_thread_timer.setInterval(10) @@ -521,46 +158,38 @@ class PushToContextSelectWindow(QtWidgets.QWidget): main_thread_timer.timeout.connect(self._on_main_thread_timer) show_timer.timeout.connect(self._on_show_timer) user_input_changed_timer.timeout.connect(self._on_user_input_timer) - asset_name_input.textChanged.connect(self._on_new_asset_change) + folder_name_input.textChanged.connect(self._on_new_asset_change) variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) - project_model.refreshed.connect(self._on_projects_refresh) - project_combobox.currentIndexChanged.connect(self._on_project_change) - asset_view.selectionModel().selectionChanged.connect( - self._on_asset_change - ) - asset_model.items_changed.connect(self._on_asset_model_change) - task_view.selectionModel().selectionChanged.connect( - self._on_task_change - ) - task_model.items_changed.connect(self._on_task_model_change) + publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) overlay_close_btn.clicked.connect(self._on_close_click) overlay_try_btn.clicked.connect(self._on_try_again_click) - controller.event_system.add_callback( - "new_asset_name.changed", self._on_controller_new_asset_change + controller.register_event_callback( + "new_folder_name.changed", + self._on_controller_new_asset_change ) - controller.event_system.add_callback( + controller.register_event_callback( "variant.changed", self._on_controller_variant_change ) - controller.event_system.add_callback( + controller.register_event_callback( "comment.changed", self._on_controller_comment_change ) - controller.event_system.add_callback( + controller.register_event_callback( "submission.enabled.changed", self._on_submission_change ) - controller.event_system.add_callback( + controller.register_event_callback( "source.changed", self._on_controller_source_change ) - controller.event_system.add_callback( + controller.register_event_callback( "submit.started", self._on_controller_submit_start ) - controller.event_system.add_callback( + controller.register_event_callback( "submit.finished", self._on_controller_submit_end ) - controller.event_system.add_callback( + controller.register_event_callback( "push.message.added", self._on_push_message ) @@ -571,20 +200,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label = header_label self._main_splitter = main_splitter - self._project_combobox = project_combobox - self._project_model = project_model - self._project_proxy = project_proxy - self._project_delegate = project_delegate - - self._asset_view = asset_view - self._asset_model = asset_model - self._asset_proxy_model = asset_proxy - - self._task_view = task_view - self._task_proxy_model = task_proxy + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget self._variant_input = variant_input - self._asset_name_input = asset_name_input + self._folder_name_input = folder_name_input self._comment_input = comment_input self._publish_btn = publish_btn @@ -600,60 +221,78 @@ class PushToContextSelectWindow(QtWidgets.QWidget): # The goal is to have controll over changes happened during user change # in UI and controller auto-changes self._variant_input_text = None - self._new_asset_name_input_text = None + self._new_folder_name_input_text = None self._comment_input_text = None - self._show_timer = show_timer - self._show_counter = 2 + self._first_show = True + self._show_timer = show_timer + self._show_counter = 0 self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None self._process_item = None + self._variant_is_valid = None + self._folder_is_valid = None + publish_btn.setEnabled(False) overlay_close_btn.setVisible(False) overlay_try_btn.setVisible(False) - if controller.user_values.new_asset_name: - asset_name_input.setText(controller.user_values.new_asset_name) - if controller.user_values.variant: - variant_input.setText(controller.user_values.variant) - self._invalidate_variant() - self._invalidate_new_asset_name() + # Support of public api function of controller + def set_source(self, project_name, version_id): + """Set source project and version. - @property - def controller(self): - return self._controller + Call the method on controller. + + Args: + project_name (Union[str, None]): Name of project. + version_id (Union[str, None]): Version id. + """ + + self._controller.set_source(project_name, version_id) def showEvent(self, event): super(PushToContextSelectWindow, self).showEvent(event) if self._first_show: self._first_show = False - self.setStyleSheet(load_stylesheet()) - self._invalidate_variant() - self._show_timer.start() + self._on_first_show() + + def refresh(self): + user_values = self._controller.get_user_values() + new_folder_name = user_values["new_folder_name"] + variant = user_values["variant"] + self._folder_name_input.setText(new_folder_name or "") + self._variant_input.setText(variant or "") + self._invalidate_variant(user_values["is_variant_valid"]) + self._invalidate_new_folder_name( + new_folder_name, user_values["is_new_folder_name_valid"] + ) + + self._projects_combobox.refresh() + + def _on_first_show(self): + width = 740 + height = 640 + inputs_width = 360 + self.setStyleSheet(load_stylesheet()) + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + self._show_timer.start() def _on_show_timer(self): - if self._show_counter == 0: - self._show_timer.stop() + if self._show_counter < 3: + self._show_counter += 1 return + self._show_timer.stop() - self._show_counter -= 1 - if self._show_counter == 1: - width = 740 - height = 640 - inputs_width = 360 - self.resize(width, height) - self._main_splitter.setSizes([width - inputs_width, inputs_width]) + self._show_counter = 0 - if self._show_counter > 0: - return - - self._controller.model.refresh_projects() + self.refresh() def _on_new_asset_change(self, text): - self._new_asset_name_input_text = text + self._new_folder_name_input_text = text self._user_input_changed_timer.start() def _on_variant_change(self, text): @@ -665,42 +304,41 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._user_input_changed_timer.start() def _on_user_input_timer(self): - asset_name = self._new_asset_name_input_text - if asset_name is not None: - self._new_asset_name_input_text = None - self._controller.user_values.set_new_asset(asset_name) + folder_name = self._new_folder_name_input_text + if folder_name is not None: + self._new_folder_name_input_text = None + self._controller.set_user_value_folder_name(folder_name) variant = self._variant_input_text if variant is not None: self._variant_input_text = None - self._controller.user_values.set_variant(variant) + self._controller.set_user_value_variant(variant) comment = self._comment_input_text if comment is not None: self._comment_input_text = None - self._controller.user_values.set_comment(comment) + self._controller.set_user_value_comment(comment) def _on_controller_new_asset_change(self, event): - asset_name = event["changes"]["new_asset_name"]["new"] + folder_name = event["new_folder_name"] if ( - self._new_asset_name_input_text is None - and asset_name != self._asset_name_input.text() + self._new_folder_name_input_text is None + and folder_name != self._folder_name_input.text() ): - self._asset_name_input.setText(asset_name) + self._folder_name_input.setText(folder_name) - self._invalidate_new_asset_name() + self._invalidate_new_folder_name(folder_name, event["is_valid"]) def _on_controller_variant_change(self, event): - is_valid_changes = event["changes"]["is_valid"] - variant = event["changes"]["variant"]["new"] + is_valid = event["is_valid"] + variant = event["variant"] if ( self._variant_input_text is None and variant != self._variant_input.text() ): self._variant_input.setText(variant) - if is_valid_changes["old"] != is_valid_changes["new"]: - self._invalidate_variant() + self._invalidate_variant(is_valid) def _on_controller_comment_change(self, event): comment = event["comment"] @@ -711,66 +349,30 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input.setText(comment) def _on_controller_source_change(self): - self._header_label.setText(self._controller.src_label) + self._header_label.setText(self._controller.get_source_label()) - def _invalidate_new_asset_name(self): - asset_name = self._controller.user_values.new_asset_name - self._task_view.setVisible(not asset_name) - - valid = None - if asset_name: - valid = self._controller.user_values.is_new_asset_name_valid - - state = "" - if valid is True: - state = "valid" - elif valid is False: - state = "invalid" - set_style_property(self._asset_name_input, "state", state) - - def _invalidate_variant(self): - valid = self._controller.user_values.is_variant_valid - state = "invalid" - if valid is True: - state = "valid" - set_style_property(self._variant_input, "state", state) - - def _on_projects_refresh(self): - self._project_proxy.sort(0, QtCore.Qt.AscendingOrder) - - def _on_project_change(self): - idx = self._project_combobox.currentIndex() - if idx < 0: - self._project_proxy.set_filter_empty_project(False) + def _invalidate_new_folder_name(self, folder_name, is_valid): + print(folder_name) + self._tasks_widget.setVisible(not folder_name) + if self._folder_is_valid is is_valid: return + self._folder_is_valid = is_valid + state = "" + if folder_name: + if is_valid is True: + state = "valid" + elif is_valid is False: + state = "invalid" + set_style_property( + self._folder_name_input, "state", state + ) - project_name = self._project_combobox.itemData(idx, PROJECT_NAME_ROLE) - self._project_proxy.set_filter_empty_project(project_name is not None) - self._controller.selection_model.select_project(project_name) - - def _on_asset_change(self): - indexes = self._asset_view.selectedIndexes() - index = next(iter(indexes), None) - asset_id = None - if index is not None: - model = self._asset_view.model() - asset_id = model.data(index, ASSET_ID_ROLE) - self._controller.selection_model.select_asset(asset_id) - - def _on_asset_model_change(self): - self._asset_proxy_model.sort(0, QtCore.Qt.AscendingOrder) - - def _on_task_model_change(self): - self._task_proxy_model.sort(0, QtCore.Qt.AscendingOrder) - - def _on_task_change(self): - indexes = self._task_view.selectedIndexes() - index = next(iter(indexes), None) - task_name = None - if index is not None: - model = self._task_view.model() - task_name = model.data(index, TASK_NAME_ROLE) - self._controller.selection_model.select_task(task_name) + def _invalidate_variant(self, is_valid): + if self._variant_is_valid is is_valid: + return + self._variant_is_valid = is_valid + state = "valid" if is_valid else "invalid" + set_style_property(self._variant_input, "state", state) def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) From 2ac76ad4afc402963e435fe1a25eae643ae526b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:22:56 +0200 Subject: [PATCH 0716/1224] moved user values model to different file --- .../tools/ayon_push_to_project/control.py | 128 ++---------------- .../ayon_push_to_project/models/__init__.py | 2 + .../models/user_values.py | 110 +++++++++++++++ 3 files changed, 123 insertions(+), 117 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/models/user_values.py diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4aef09156f..4cba437553 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -1,4 +1,3 @@ -import re import threading from openpype.client import ( @@ -10,10 +9,7 @@ from openpype.client import ( from openpype.settings import get_project_settings from openpype.lib import prepare_template_data from openpype.lib.events import QueuedEventSystem -from openpype.pipeline.create import ( - SUBSET_NAME_ALLOWED_SYMBOLS, - get_subset_name_template, -) +from openpype.pipeline.create import get_subset_name_template from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .control_integrate import ( @@ -21,114 +17,10 @@ from .control_integrate import ( ProjectPushItemProcess, ProjectPushItemStatus, ) -from .models import PushToProjectSelectionModel - - -class UserPublishValues: - """Helper object to validate values required for push to different project. - - Args: - controller (PushToContextController): Event system to catch - and emit events. - """ - - folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") - variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) - - def __init__(self, controller): - self._controller = controller - self._new_folder_name = None - self._variant = None - self._comment = None - self._is_variant_valid = False - self._is_new_folder_name_valid = False - - self.set_new_folder_name("") - self.set_variant("") - self.set_comment("") - - @property - def new_folder_name(self): - return self._new_folder_name - - @property - def variant(self): - return self._variant - - @property - def comment(self): - return self._comment - - @property - def is_variant_valid(self): - return self._is_variant_valid - - @property - def is_new_folder_name_valid(self): - return self._is_new_folder_name_valid - - @property - def is_valid(self): - return self.is_variant_valid and self.is_new_folder_name_valid - - def get_data(self): - return { - "new_folder_name": self._new_folder_name, - "variant": self._variant, - "comment": self._comment, - "is_variant_valid": self._is_variant_valid, - "is_new_folder_name_valid": self._is_new_folder_name_valid, - "is_valid": self.is_valid - } - - def set_variant(self, variant): - if variant == self._variant: - return - - self._variant = variant - is_valid = False - if variant: - is_valid = self.variant_regex.match(variant) is not None - self._is_variant_valid = is_valid - - self._controller.emit_event( - "variant.changed", - { - "variant": variant, - "is_valid": self._is_variant_valid, - }, - "user_values" - ) - - def set_new_folder_name(self, folder_name): - if self._new_folder_name == folder_name: - return - - self._new_folder_name = folder_name - is_valid = True - if folder_name: - is_valid = ( - self.folder_name_regex.match(folder_name) is not None - ) - self._is_new_folder_name_valid = is_valid - self._controller.emit_event( - "new_folder_name.changed", - { - "new_folder_name": self._new_folder_name, - "is_valid": self._is_new_folder_name_valid, - }, - "user_values" - ) - - def set_comment(self, comment): - if comment == self._comment: - return - self._comment = comment - self._controller.emit_event( - "comment.changed", - {"comment": comment}, - "user_values" - ) +from .models import ( + PushToProjectSelectionModel, + UserPublishValuesModel, +) class PushToContextController: @@ -139,7 +31,7 @@ class PushToContextController: self._hierarchy_model = HierarchyModel(self) self._selection_model = PushToProjectSelectionModel(self) - self._user_values = UserPublishValues(self) + self._user_values = UserPublishValuesModel(self) self._src_project_name = None self._src_version_id = None @@ -229,18 +121,23 @@ class PushToContextController: def set_user_value_folder_name(self, folder_name): self._user_values.set_new_folder_name(folder_name) + self._invalidate() def set_user_value_variant(self, variant): self._user_values.set_variant(variant) + self._invalidate() def set_user_value_comment(self, comment): self._user_values.set_comment(comment) + self._invalidate() def set_selected_project(self, project_name): self._selection_model.set_selected_project(project_name) + self._invalidate() def set_selected_folder(self, folder_id): self._selection_model.set_selected_folder(folder_id) + self._invalidate() def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) @@ -394,9 +291,6 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def _on_project_change(self, event): - self._invalidate() - def _invalidate(self): submission_enabled = self._check_submit_validations() if submission_enabled == self._submission_enabled: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 0123fc9355..48eb5e9f14 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,6 +1,8 @@ from .selection import PushToProjectSelectionModel +from .user_values import UserPublishValuesModel __all__ = ( "PushToProjectSelectionModel", + "UserPublishValuesModel", ) diff --git a/openpype/tools/ayon_push_to_project/models/user_values.py b/openpype/tools/ayon_push_to_project/models/user_values.py new file mode 100644 index 0000000000..2a4faeb136 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/user_values.py @@ -0,0 +1,110 @@ +import re + +from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS + + +class UserPublishValuesModel: + """Helper object to validate values required for push to different project. + + Args: + controller (PushToContextController): Event system to catch + and emit events. + """ + + folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, controller): + self._controller = controller + self._new_folder_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_folder_name_valid = False + + self.set_new_folder_name("") + self.set_variant("") + self.set_comment("") + + @property + def new_folder_name(self): + return self._new_folder_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_folder_name_valid(self): + return self._is_new_folder_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_folder_name_valid + + def get_data(self): + return { + "new_folder_name": self._new_folder_name, + "variant": self._variant, + "comment": self._comment, + "is_variant_valid": self._is_variant_valid, + "is_new_folder_name_valid": self._is_new_folder_name_valid, + "is_valid": self.is_valid + } + + def set_variant(self, variant): + if variant == self._variant: + return + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + self._controller.emit_event( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + }, + "user_values" + ) + + def set_new_folder_name(self, folder_name): + if self._new_folder_name == folder_name: + return + + self._new_folder_name = folder_name + is_valid = True + if folder_name: + is_valid = ( + self.folder_name_regex.match(folder_name) is not None + ) + self._is_new_folder_name_valid = is_valid + self._controller.emit_event( + "new_folder_name.changed", + { + "new_folder_name": self._new_folder_name, + "is_valid": self._is_new_folder_name_valid, + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + self._comment = comment + self._controller.emit_event( + "comment.changed", + {"comment": comment}, + "user_values" + ) From 7951c95f095e5418d0deb2d19e934868f6686238 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:23:45 +0200 Subject: [PATCH 0717/1224] renamed '_get_source_label' to '_prepare_source_label' --- openpype/tools/ayon_push_to_project/control.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4cba437553..1e6cbd55d4 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -102,7 +102,7 @@ class PushToContextController: def get_source_label(self): if self._src_label is None: - self._src_label = self._get_source_label() + self._src_label = self._prepare_source_label() return self._src_label def get_project_items(self, sender=None): @@ -182,7 +182,7 @@ class PushToContextController: self._process_thread.join() self._process_thread = None - def _get_source_label(self): + def _prepare_source_label(self): if not self._src_project_name or not self._src_version_id: return "Source is not defined" @@ -190,14 +190,14 @@ class PushToContextController: if not asset_doc: return "Source is invalid" - asset_path_parts = list(asset_doc["data"]["parents"]) - asset_path_parts.append(asset_doc["name"]) - asset_path = "/".join(asset_path_parts) + folder_path_parts = list(asset_doc["data"]["parents"]) + folder_path_parts.append(asset_doc["name"]) + folder_path = "/".join(folder_path_parts) subset_doc = self._src_subset_doc version_doc = self._src_version_doc return "Source: {}/{}/{}/v{:0>3}".format( self._src_project_name, - asset_path, + folder_path, subset_doc["name"], version_doc["name"] ) From 37da54b438a3d109b6bfab21f49d046ef89aa38c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:24:07 +0200 Subject: [PATCH 0718/1224] implemented helper method to trigger controller events --- .../tools/ayon_push_to_project/control.py | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 1e6cbd55d4..d07b915bf7 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -92,12 +92,12 @@ class PushToContextController: if comment: self._user_values.set_comment(comment) - self._event_system.emit( - "source.changed", { + self._emit_event( + "source.changed", + { "project_name": project_name, "version_id": version_id - }, - "controller" + } ) def get_source_label(self): @@ -165,7 +165,7 @@ class PushToContextController: status_item = ProjectPushItemStatus(event_system=self._event_system) process_item = ProjectPushItemProcess(item, status_item) self._process_item = process_item - self._event_system.emit("submit.started", {}, "controller") + self._emit_event("submit.started") if wait: self._submit_callback() self._process_item = None @@ -291,17 +291,6 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def _invalidate(self): - submission_enabled = self._check_submit_validations() - if submission_enabled == self._submission_enabled: - return - self._submission_enabled = submission_enabled - self._event_system.emit( - "submission.enabled.changed", - {"enabled": submission_enabled}, - "controller" - ) - def _check_submit_validations(self): if not self._user_values.is_valid: return False @@ -314,17 +303,31 @@ class PushToContextController: and not self._selection_model.get_selected_folder_id() ): return False - return True + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._emit_event( + "submission.enabled.changed", + {"enabled": submission_enabled} + ) + def _submit_callback(self): process_item = self._process_item if process_item is None: return process_item.process() - self._event_system.emit("submit.finished", {}, "controller") + self._emit_event("submit.finished", {}) if process_item is self._process_item: self._process_item = None + def _emit_event(self, topic, data=None): + if data is None: + data = {} + self.emit_event(topic, data, "controller") + def _create_event_system(self): return QueuedEventSystem() From faadf3582c43ef19126d6d351501a4e8ccdbdc3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:24:16 +0200 Subject: [PATCH 0719/1224] removed debug print --- openpype/tools/ayon_push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index d5b2823490..57c4c2619f 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -352,7 +352,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label.setText(self._controller.get_source_label()) def _invalidate_new_folder_name(self, folder_name, is_valid): - print(folder_name) self._tasks_widget.setVisible(not folder_name) if self._folder_is_valid is is_valid: return From e729cc1964cfd351ba47057a48df143c4379f2b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:26:16 +0200 Subject: [PATCH 0720/1224] moved 'control_integrate.py' to models as 'integrate.py' --- openpype/tools/ayon_push_to_project/control.py | 10 +++++----- openpype/tools/ayon_push_to_project/models/__init__.py | 10 ++++++++++ .../{control_integrate.py => models/integrate.py} | 0 3 files changed, 15 insertions(+), 5 deletions(-) rename openpype/tools/ayon_push_to_project/{control_integrate.py => models/integrate.py} (100%) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index d07b915bf7..4fc011da09 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -12,15 +12,15 @@ from openpype.lib.events import QueuedEventSystem from openpype.pipeline.create import get_subset_name_template from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel -from .control_integrate import ( +from .models import ( + PushToProjectSelectionModel, + + UserPublishValuesModel, + ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, ) -from .models import ( - PushToProjectSelectionModel, - UserPublishValuesModel, -) class PushToContextController: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 48eb5e9f14..e8c0fae02e 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,8 +1,18 @@ from .selection import PushToProjectSelectionModel from .user_values import UserPublishValuesModel +from .integrate import ( + ProjectPushItem, + ProjectPushItemProcess, + ProjectPushItemStatus, +) __all__ = ( "PushToProjectSelectionModel", + "UserPublishValuesModel", + + "ProjectPushItem", + "ProjectPushItemProcess", + "ProjectPushItemStatus", ) diff --git a/openpype/tools/ayon_push_to_project/control_integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py similarity index 100% rename from openpype/tools/ayon_push_to_project/control_integrate.py rename to openpype/tools/ayon_push_to_project/models/integrate.py From ed4c306c43907f5ea8bbf0860aa08d5abb032dcc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 12:16:44 +0200 Subject: [PATCH 0721/1224] modified integration to avoid direct access to integrate objects --- .../tools/ayon_push_to_project/control.py | 47 +- .../ayon_push_to_project/models/__init__.py | 2 + .../ayon_push_to_project/models/integrate.py | 572 +++++++++--------- .../tools/ayon_push_to_project/ui/window.py | 18 +- 4 files changed, 328 insertions(+), 311 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4fc011da09..0a19136701 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -14,12 +14,8 @@ from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .models import ( PushToProjectSelectionModel, - UserPublishValuesModel, - - ProjectPushItem, - ProjectPushItemProcess, - ProjectPushItemStatus, + IntegrateModel, ) @@ -29,6 +25,7 @@ class PushToContextController: self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) + self._integrate_model = IntegrateModel(self) self._selection_model = PushToProjectSelectionModel(self) self._user_values = UserPublishValuesModel(self) @@ -42,7 +39,7 @@ class PushToContextController: self._submission_enabled = False self._process_thread = None - self._process_item = None + self._process_item_id = None self.set_source(project_name, version_id) @@ -58,6 +55,13 @@ class PushToContextController: self._event_system.add_callback(topic, callback) def set_source(self, project_name, version_id): + """Set source project and version. + + Args: + project_name (Union[str, None]): Source project name. + version_id (Union[str, None]): Source version id. + """ + if ( project_name == self._src_project_name and version_id == self._src_version_id @@ -101,6 +105,12 @@ class PushToContextController: ) def get_source_label(self): + """Get source label. + + Returns: + str: Label describing source project and version as path. + """ + if self._src_label is None: self._src_label = self._prepare_source_label() return self._src_label @@ -142,6 +152,9 @@ class PushToContextController: def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) + def get_process_item_status(self, item_id): + return self._integrate_model.get_item_status(item_id) + # Processing methods def submit(self, wait=True): if not self._submission_enabled: @@ -150,7 +163,7 @@ class PushToContextController: if self._process_thread is not None: return - item = ProjectPushItem( + item_id = self._integrate_model.create_process_item( self._src_project_name, self._src_version_id, self._selection_model.get_selected_project_name(), @@ -162,19 +175,17 @@ class PushToContextController: dst_version=1 ) - status_item = ProjectPushItemStatus(event_system=self._event_system) - process_item = ProjectPushItemProcess(item, status_item) - self._process_item = process_item + self._process_item_id = item_id self._emit_event("submit.started") if wait: self._submit_callback() - self._process_item = None - return process_item + self._process_item_id = None + return item_id thread = threading.Thread(target=self._submit_callback) self._process_thread = thread thread.start() - return process_item + return item_id def wait_for_process_thread(self): if self._process_thread is None: @@ -316,13 +327,13 @@ class PushToContextController: ) def _submit_callback(self): - process_item = self._process_item - if process_item is None: + process_item_id = self._process_item_id + if process_item_id is None: return - process_item.process() + self._integrate_model.integrate_item(process_item_id) self._emit_event("submit.finished", {}) - if process_item is self._process_item: - self._process_item = None + if process_item_id == self._process_item_id: + self._process_item_id = None def _emit_event(self, topic, data=None): if data is None: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index e8c0fae02e..5f909437a7 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -4,6 +4,7 @@ from .integrate import ( ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, + IntegrateModel, ) @@ -15,4 +16,5 @@ __all__ = ( "ProjectPushItem", "ProjectPushItemProcess", "ProjectPushItemStatus", + "IntegrateModel", ) diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py index a822339ccf..b3de69c79a 100644 --- a/openpype/tools/ayon_push_to_project/models/integrate.py +++ b/openpype/tools/ayon_push_to_project/models/integrate.py @@ -6,6 +6,7 @@ import itertools import datetime import sys import traceback +import uuid from bson.objectid import ObjectId @@ -98,38 +99,62 @@ class ProjectPushItem: src_project_name, src_version_id, dst_project_name, - dst_asset_id, + dst_folder_id, dst_task_name, variant, - comment=None, - new_asset_name=None, - dst_version=None + comment, + new_folder_name, + dst_version, + item_id=None, ): + if not item_id: + item_id = uuid.uuid4().hex self.src_project_name = src_project_name self.src_version_id = src_version_id self.dst_project_name = dst_project_name - self.dst_asset_id = dst_asset_id + self.dst_folder_id = dst_folder_id self.dst_task_name = dst_task_name self.dst_version = dst_version self.variant = variant - self.new_asset_name = new_asset_name + self.new_folder_name = new_folder_name self.comment = comment or "" - self._id = "|".join([ - src_project_name, - src_version_id, - dst_project_name, - str(dst_asset_id), - str(new_asset_name), - str(dst_task_name), - str(dst_version) - ]) + self.item_id = item_id + self._repr_value = None @property - def id(self): - return self._id + def _repr(self): + if not self._repr_value: + self._repr_value = "|".join([ + self.src_project_name, + self.src_version_id, + self.dst_project_name, + str(self.dst_folder_id), + str(self.new_folder_name), + str(self.dst_task_name), + str(self.dst_version) + ]) + return self._repr_value def __repr__(self): - return "<{} - {}>".format(self.__class__.__name__, self.id) + return "<{} - {}>".format(self.__class__.__name__, self._repr) + + def to_data(self): + return { + "src_project_name": self.src_project_name, + "src_version_id": self.src_version_id, + "dst_project_name": self.dst_project_name, + "dst_folder_id": self.dst_folder_id, + "dst_task_name": self.dst_task_name, + "dst_version": self.dst_version, + "variant": self.variant, + "comment": self.comment, + "new_folder_name": self.new_folder_name, + "item_id": self.item_id, + } + + @classmethod + def from_data(cls, data): + return cls(**data) class StatusMessage: @@ -149,49 +174,17 @@ class StatusMessage: class ProjectPushItemStatus: def __init__( self, + started=False, failed=False, finished=False, fail_reason=None, - formatted_traceback=None, - messages=None, - event_system=None + full_traceback=None ): - if messages is None: - messages = [] - self._failed = failed - self._finished = finished - self._fail_reason = fail_reason - self._traceback = formatted_traceback - self._messages = messages - self._event_system = event_system - - def emit_event(self, topic, data=None): - if self._event_system is None: - return - - self._event_system.emit(topic, data or {}, "push.status") - - def get_finished(self): - """Processing of push to project finished. - - Returns: - bool: Finished. - """ - - return self._finished - - def set_finished(self, finished=True): - """Mark status as finished. - - Args: - finished (bool): Processing finished (failed or not). - """ - - if finished != self._finished: - self._finished = finished - self.emit_event("push.finished.changed", {"finished": finished}) - - finished = property(get_finished, set_finished) + self.started = started + self.failed = failed + self.finished = finished + self.fail_reason = fail_reason + self.full_traceback = full_traceback def set_failed(self, fail_reason, exc_info=None): """Set status as failed. @@ -201,8 +194,8 @@ class ProjectPushItemStatus: is set to 'True' and reason is not set. Args: - failed (bool): Push to project failed. fail_reason (str): Reason why failed. + exc_info(tuple): Exception info. """ failed = True @@ -215,84 +208,22 @@ class ProjectPushItemStatus: if not fail_reason: fail_reason = "Failed without specified reason" - if ( - self._failed == failed - and self._traceback == full_traceback - and self._fail_reason == fail_reason - ): - return + self.failed = failed + self.fail_reason = fail_reason or None + self.full_traceback = full_traceback - self._failed = failed - self._fail_reason = fail_reason or None - self._traceback = full_traceback + def to_data(self): + return { + "started": self.started, + "failed": self.failed, + "finished": self.finished, + "fail_reason": self.fail_reason, + "full_traceback": self.full_traceback, + } - self.emit_event( - "push.failed.changed", - { - "failed": failed, - "reason": fail_reason, - "traceback": full_traceback - } - ) - - @property - def failed(self): - """Processing failed. - - Returns: - bool: Processing failed. - """ - - return self._failed - - @property - def fail_reason(self): - """Reason why push to process failed. - - Returns: - Union[str, None]: Reason why push failed or None. - """ - - return self._fail_reason - - @property - def traceback(self): - """Traceback of failed process. - - Traceback is available only if unhandled exception happened. - - Returns: - Union[str, None]: Formatted traceback. - """ - - return self._traceback - - # Loggin helpers - # TODO better logging - def add_message(self, message, level): - message_obj = StatusMessage(message, level) - self._messages.append(message_obj) - self.emit_event( - "push.message.added", - {"message": message, "level": level} - ) - print(message_obj) - return message_obj - - def debug(self, message): - return self.add_message(message, "debug") - - def info(self, message): - return self.add_message(message, "info") - - def warning(self, message): - return self.add_message(message, "warning") - - def error(self, message): - return self.add_message(message, "error") - - def critical(self, message): - return self.add_message(message, "critical") + @classmethod + def from_data(cls, data): + return cls(**data) class ProjectPushRepreItem: @@ -508,22 +439,21 @@ class ProjectPushRepreItem: class ProjectPushItemProcess: """ Args: + model (IntegrateModel): Model which is processing item. item (ProjectPushItem): Item which is being processed. - item_status (ProjectPushItemStatus): Object to store status. """ # TODO where to get host?!!! host_name = "republisher" - def __init__(self, item, item_status=None): + def __init__(self, model, item): + self._model = model self._item = item - self._src_project_doc = None self._src_asset_doc = None self._src_subset_doc = None self._src_version_doc = None self._src_repre_items = None - self._src_anatomy = None self._project_doc = None self._anatomy = None @@ -539,85 +469,98 @@ class ProjectPushItemProcess: self._project_settings = None self._template_name = None - if item_status is None: - item_status = ProjectPushItemStatus() - self._status = item_status + self._status = ProjectPushItemStatus() self._operations = OperationsSession() self._file_transaction = FileTransaction() - @property - def status(self): - return self._status + self._messages = [] @property - def src_project_doc(self): - return self._src_project_doc + def item_id(self): + return self._item.item_id @property - def src_anatomy(self): - return self._src_anatomy + def started(self): + return self._status.started - @property - def src_asset_doc(self): - return self._src_asset_doc + def get_status_data(self): + return self._status.to_data() - @property - def src_subset_doc(self): - return self._src_subset_doc + def integrate(self): + self._status.started = True + try: + self._log_info("Process started") + self._fill_source_variables() + self._log_info("Source entities were found") + self._fill_destination_project() + self._log_info("Destination project was found") + self._fill_or_create_destination_asset() + self._log_info("Destination asset was determined") + self._determine_family() + self._determine_publish_template_name() + self._determine_subset_name() + self._make_sure_subset_exists() + self._make_sure_version_exists() + self._log_info("Prerequirements were prepared") + self._integrate_representations() + self._log_info("Integration finished") - @property - def src_version_doc(self): - return self._src_version_doc + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) - @property - def src_repre_items(self): - return self._src_repre_items + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) - @property - def project_doc(self): - return self._project_doc + finally: + self._status.finished = True + self._emit_event( + "push.finished.changed", + { + "finished": True, + "item_id": self.item_id, + } + ) - @property - def anatomy(self): - return self._anatomy + def _emit_event(self, topic, data): + self._model.emit_event(topic, data) - @property - def project_settings(self): - return self._project_settings + # Loggin helpers + # TODO better logging + def _add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self._emit_event( + "push.message.added", + { + "message": message, + "level": level, + "item_id": self.item_id, + } + ) + print(message_obj) + return message_obj - @property - def asset_doc(self): - return self._asset_doc + def _log_debug(self, message): + return self._add_message(message, "debug") - @property - def task_info(self): - return self._task_info + def _log_info(self, message): + return self._add_message(message, "info") - @property - def subset_doc(self): - return self._subset_doc + def _log_warning(self, message): + return self._add_message(message, "warning") - @property - def version_doc(self): - return self._version_doc + def _log_error(self, message): + return self._add_message(message, "error") - @property - def variant(self): - return self._item.variant + def _log_critical(self, message): + return self._add_message(message, "critical") - @property - def family(self): - return self._family - - @property - def subset_name(self): - return self._subset_name - - @property - def template_name(self): - return self._template_name - - def fill_source_variables(self): + def _fill_source_variables(self): src_project_name = self._item.src_project_name src_version_id = self._item.src_version_id @@ -626,9 +569,14 @@ class ProjectPushItemProcess: self._status.set_failed( f"Source project \"{src_project_name}\" was not found" ) + + self._emit_event( + "push.failed.changed", + {"item_id": self.item_id} + ) raise PushToProjectError(self._status.fail_reason) - self._status.debug(f"Project '{src_project_name}' found") + self._log_debug(f"Project '{src_project_name}' found") version_doc = get_version_by_id(src_project_name, src_version_id) if not version_doc: @@ -666,7 +614,7 @@ class ProjectPushItemProcess: ProjectPushRepreItem(repre_doc, anatomy.roots) for repre_doc in repre_docs ] - self._status.debug(( + self._log_debug(( f"Found {len(repre_items)} representations on" f" version {src_version_id} in project '{src_project_name}'" )) @@ -677,14 +625,12 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._src_anatomy = anatomy - self._src_project_doc = project_doc self._src_asset_doc = asset_doc self._src_subset_doc = subset_doc self._src_version_doc = version_doc self._src_repre_items = repre_items - def fill_destination_project(self): + def _fill_destination_project(self): # --- Destination entities --- dst_project_name = self._item.dst_project_name # Validate project existence @@ -695,7 +641,7 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._status.debug( + self._log_debug( f"Destination project '{dst_project_name}' found" ) self._project_doc = dst_project_doc @@ -739,7 +685,7 @@ class ProjectPushItemProcess: )) raise PushToProjectError(self._status.fail_reason) - self._status.debug(( + self._log_debug(( f"Found already existing asset with name \"{other_name}\"" f" which match requested name \"{asset_name}\"" )) @@ -780,18 +726,18 @@ class ProjectPushItemProcess: asset_doc["type"], asset_doc ) - self._status.info( + self._log_info( f"Creating new asset with name \"{asset_name}\"" ) self._created_asset_doc = asset_doc return asset_doc - def fill_or_create_destination_asset(self): + def _fill_or_create_destination_asset(self): dst_project_name = self._item.dst_project_name - dst_asset_id = self._item.dst_asset_id + dst_folder_id = self._item.dst_folder_id dst_task_name = self._item.dst_task_name - new_asset_name = self._item.new_asset_name - if not dst_asset_id and not new_asset_name: + new_folder_name = self._item.new_folder_name + if not dst_folder_id and not new_folder_name: self._status.set_failed( "Push item does not have defined destination asset" ) @@ -799,25 +745,25 @@ class ProjectPushItemProcess: # Get asset document parent_asset_doc = None - if dst_asset_id: + if dst_folder_id: parent_asset_doc = get_asset_by_id( - self._item.dst_project_name, self._item.dst_asset_id + self._item.dst_project_name, self._item.dst_folder_id ) if not parent_asset_doc: self._status.set_failed( - f"Could find asset with id \"{dst_asset_id}\"" + f"Could find asset with id \"{dst_folder_id}\"" f" in project \"{dst_project_name}\"" ) raise PushToProjectError(self._status.fail_reason) - if not new_asset_name: + if not new_folder_name: asset_doc = parent_asset_doc else: asset_doc = self._create_asset( - self.src_asset_doc, - self.project_doc, + self._src_asset_doc, + self._project_doc, parent_asset_doc, - new_asset_name + new_folder_name ) self._asset_doc = asset_doc if not dst_task_name: @@ -842,12 +788,13 @@ class ProjectPushItemProcess: task_info["name"] = dst_task_name # Fill rest of task information based on task type task_type = task_info["type"] - task_type_info = self.project_doc["config"]["tasks"].get(task_type, {}) + task_type_info = self._project_doc["config"]["tasks"].get( + task_type, {}) task_info.update(task_type_info) self._task_info = task_info - def determine_family(self): - subset_doc = self.src_subset_doc + def _determine_family(self): + subset_doc = self._src_subset_doc family = subset_doc["data"].get("family") families = subset_doc["data"].get("families") if not family and families: @@ -859,48 +806,48 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._status.debug( + self._log_debug( f"Publishing family is '{family}' (Based on source subset)" ) self._family = family - def determine_publish_template_name(self): + def _determine_publish_template_name(self): template_name = get_publish_template_name( self._item.dst_project_name, self.host_name, - self.family, - self.task_info.get("name"), - self.task_info.get("type"), - project_settings=self.project_settings + self._family, + self._task_info.get("name"), + self._task_info.get("type"), + project_settings=self._project_settings ) - self._status.debug( + self._log_debug( f"Using template '{template_name}' for integration" ) self._template_name = template_name - def determine_subset_name(self): - family = self.family - asset_doc = self.asset_doc - task_info = self.task_info + def _determine_subset_name(self): + family = self._family + asset_doc = self._asset_doc + task_info = self._task_info subset_name = get_subset_name( family, - self.variant, + self._item.variant, task_info.get("name"), asset_doc, project_name=self._item.dst_project_name, host_name=self.host_name, - project_settings=self.project_settings + project_settings=self._project_settings ) - self._status.info( + self._log_info( f"Push will be integrating to subset with name '{subset_name}'" ) self._subset_name = subset_name - def make_sure_subset_exists(self): + def _make_sure_subset_exists(self): project_name = self._item.dst_project_name - asset_id = self.asset_doc["_id"] - subset_name = self.subset_name - family = self.family + asset_id = self._asset_doc["_id"] + subset_name = self._subset_name + family = self._family subset_doc = get_subset_by_name(project_name, subset_name, asset_id) if subset_doc: self._subset_doc = subset_doc @@ -915,13 +862,13 @@ class ProjectPushItemProcess: self._operations.create_entity(project_name, "subset", subset_doc) self._subset_doc = subset_doc - def make_sure_version_exists(self): + def _make_sure_version_exists(self): """Make sure version document exits in database.""" project_name = self._item.dst_project_name version = self._item.dst_version - src_version_doc = self.src_version_doc - subset_doc = self.subset_doc + src_version_doc = self._src_version_doc + subset_doc = self._subset_doc subset_id = subset_doc["_id"] src_data = src_version_doc["data"] families = subset_doc["data"].get("families") @@ -947,8 +894,8 @@ class ProjectPushItemProcess: version = get_versioning_start( project_name, self.host_name, - task_name=self.task_info["name"], - task_type=self.task_info["type"], + task_name=self._task_info["name"], + task_type=self._task_info["type"], family=families[0], subset=subset_doc["name"] ) @@ -982,16 +929,16 @@ class ProjectPushItemProcess: self._version_doc = version_doc - def integrate_representations(self): + def _integrate_representations(self): try: - self._integrate_representations() + self._real_integrate_representations() except Exception: self._operations.clear() self._file_transaction.rollback() raise - def _integrate_representations(self): - version_doc = self.version_doc + def _real_integrate_representations(self): + version_doc = self._version_doc version_id = version_doc["_id"] existing_repres = get_representations( self._item.dst_project_name, @@ -1001,17 +948,17 @@ class ProjectPushItemProcess: repre_doc["name"].lower(): repre_doc for repre_doc in existing_repres } - template_name = self.template_name - anatomy = self.anatomy + template_name = self._template_name + anatomy = self._anatomy formatting_data = get_template_data( - self.project_doc, - self.asset_doc, - self.task_info.get("name"), + self._project_doc, + self._asset_doc, + self._task_info.get("name"), self.host_name ) formatting_data.update({ - "subset": self.subset_name, - "family": self.family, + "subset": self._subset_name, + "family": self._family, "version": version_doc["name"] }) @@ -1021,19 +968,19 @@ class ProjectPushItemProcess: file_template = StringTemplate( anatomy.templates[template_name]["file"] ) - self._status.info("Preparing files to transfer") + self._log_info("Preparing files to transfer") processed_repre_items = self._prepare_file_transactions( anatomy, template_name, formatting_data, file_template ) self._file_transaction.process() - self._status.info("Preparing database changes") + self._log_info("Preparing database changes") self._prepare_database_operations( version_id, processed_repre_items, path_template, existing_repres_by_low_name ) - self._status.info("Finalization") + self._log_info("Finalization") self._operations.commit() self._file_transaction.finalize() @@ -1041,7 +988,7 @@ class ProjectPushItemProcess: self, anatomy, template_name, formatting_data, file_template ): processed_repre_items = [] - for repre_item in self.src_repre_items: + for repre_item in self._src_repre_items: repre_doc = repre_item.repre_doc repre_name = repre_doc["name"] repre_format_data = copy.deepcopy(formatting_data) @@ -1050,6 +997,9 @@ class ProjectPushItemProcess: ext = os.path.splitext(src_file.path)[-1] repre_format_data["ext"] = ext[1:] break + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name template_obj = anatomy.templates_obj[template_name]["folder"] folder_path = template_obj.format_strict(formatting_data) @@ -1177,34 +1127,86 @@ class ProjectPushItemProcess: {"type": "archived_representation"} ) - def process(self): - try: - self._status.info("Process started") - self.fill_source_variables() - self._status.info("Source entities were found") - self.fill_destination_project() - self._status.info("Destination project was found") - self.fill_or_create_destination_asset() - self._status.info("Destination asset was determined") - self.determine_family() - self.determine_publish_template_name() - self.determine_subset_name() - self.make_sure_subset_exists() - self.make_sure_version_exists() - self._status.info("Prerequirements were prepared") - self.integrate_representations() - self._status.info("Integration finished") - except PushToProjectError as exc: - if not self._status.failed: - self._status.set_failed(str(exc)) +class IntegrateModel: + def __init__(self, controller): + self._controller = controller + self._process_items = {} - except Exception as exc: - _exc, _value, _tb = sys.exc_info() - self._status.set_failed( - "Unhandled error happened: {}".format(str(exc)), - (_exc, _value, _tb) - ) + def reset(self): + self._process_items = {} - finally: - self._status.set_finished() + def emit_event(self, topic, data=None, source=None): + self._controller.emit_event(topic, data, source) + + def create_process_item( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment, + new_folder_name, + dst_version, + ): + """Create new item for integration. + + Args: + src_project_name (str): Source project name. + src_version_id (str): Source version id. + dst_project_name (str): Destination project name. + dst_folder_id (str): Destination folder id. + dst_task_name (str): Destination task name. + variant (str): Variant name. + comment (Union[str, None]): Comment. + new_folder_name (Union[str, None]): New folder name. + dst_version (int): Destination version number. + + Returns: + str: Item id. The id can be used to trigger integration or get + status information. + """ + + item = ProjectPushItem( + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment=comment, + new_folder_name=new_folder_name, + dst_version=dst_version + ) + process_item = ProjectPushItemProcess(self, item) + self._process_items[item.item_id] = process_item + return item.item_id + + def integrate_item(self, item_id): + """Start integration of item. + + Args: + item_id (str): Item id which should be integrated. + """ + + item = self._process_items.get(item_id) + if item is None or item.started: + return + item.integrate() + + def get_item_status(self, item_id): + """Status of an item. + + Args: + item_id (str): Item id for which status should be returned. + + Returns: + dict[str, Any]: Status data. + """ + + item = self._process_items.get(item_id) + if item is not None: + return item.get_status_data() + return None diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index 57c4c2619f..535c01c643 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -231,7 +231,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None - self._process_item = None + self._process_item_id = None self._variant_is_valid = None self._folder_is_valid = None @@ -380,10 +380,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self.close() def _on_select_click(self): - self._process_item = self._controller.submit(wait=False) + self._process_item_id = self._controller.submit(wait=False) def _on_try_again_click(self): - self._process_item = None + self._process_item_id = None self._last_submit_message = None self._overlay_close_btn.setVisible(False) @@ -395,9 +395,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_label.setText(self._last_submit_message) self._last_submit_message = None - process_status = self._process_item.status - push_failed = process_status.failed - fail_traceback = process_status.traceback + process_status = self._controller.get_process_item_status( + self._process_item_id + ) + push_failed = process_status["failed"] + fail_traceback = process_status["full_traceback"] if self._main_thread_timer_can_stop: self._main_thread_timer.stop() self._overlay_close_btn.setVisible(True) @@ -405,7 +407,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_try_btn.setVisible(True) if push_failed: - message = "Push Failed:\n{}".format(process_status.fail_reason) + message = "Push Failed:\n{}".format(process_status["fail_reason"]) if fail_traceback: message += "\n{}".format(fail_traceback) self._overlay_label.setText(message) @@ -415,7 +417,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): # Join thread in controller self._controller.wait_for_process_thread() # Reset process item to None - self._process_item = None + self._process_item_id = None def _on_controller_submit_start(self): self._main_thread_timer_can_stop = False From 74b73648180b06966fbd44dc7fef995f073c080c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 12:24:18 +0200 Subject: [PATCH 0722/1224] fix re-use of 'output' for representation --- openpype/tools/ayon_push_to_project/models/integrate.py | 2 ++ openpype/tools/push_to_project/control_integrate.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py index b3de69c79a..976d8cb4f0 100644 --- a/openpype/tools/ayon_push_to_project/models/integrate.py +++ b/openpype/tools/ayon_push_to_project/models/integrate.py @@ -997,6 +997,8 @@ class ProjectPushItemProcess: ext = os.path.splitext(src_file.path)[-1] repre_format_data["ext"] = ext[1:] break + + # Re-use 'output' from source representation repre_output_name = repre_doc["context"].get("output") if repre_output_name is not None: repre_format_data["output"] = repre_output_name diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py index a822339ccf..9f083d8eb7 100644 --- a/openpype/tools/push_to_project/control_integrate.py +++ b/openpype/tools/push_to_project/control_integrate.py @@ -1051,6 +1051,11 @@ class ProjectPushItemProcess: repre_format_data["ext"] = ext[1:] break + # Re-use 'output' from source representation + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name + template_obj = anatomy.templates_obj[template_name]["folder"] folder_path = template_obj.format_strict(formatting_data) repre_context = folder_path.used_values From 38883e4bddd699db3edd33b393adf0da347b5ffd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 14:04:16 +0200 Subject: [PATCH 0723/1224] removed unnecessary imports --- .../tools/ayon_push_to_project/models/__init__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 5f909437a7..99355b4296 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,20 +1,10 @@ from .selection import PushToProjectSelectionModel from .user_values import UserPublishValuesModel -from .integrate import ( - ProjectPushItem, - ProjectPushItemProcess, - ProjectPushItemStatus, - IntegrateModel, -) +from .integrate import IntegrateModel __all__ = ( "PushToProjectSelectionModel", - "UserPublishValuesModel", - - "ProjectPushItem", - "ProjectPushItemProcess", - "ProjectPushItemStatus", "IntegrateModel", ) From c0ab1c368e281ba21b2b9d1133475055524220b5 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 19 Oct 2023 13:00:16 +0000 Subject: [PATCH 0724/1224] [Automated] Release --- CHANGELOG.md | 373 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 375 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5cf2c4d2..58428ab4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,379 @@ # Changelog +## [3.17.3](https://github.com/ynput/OpenPype/tree/3.17.3) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.2...3.17.3) + +### **🆕 New features** + + +

+Maya: Multi-shot Layout Creator #5710 + +New Multi-shot Layout creator is a way of automating creation of the new Layout instances in Maya, associated with correct shots, frame ranges and Camera Sequencer in Maya. + + +___ + +
+ + +
+Colorspace: ociolook file product type workflow #5541 + +Traypublisher support for publishing of colorspace look files (ociolook) which are json files holding any LUT files. This new product is available for loading in Nuke host at the moment.Added colorspace selector to publisher attribute with better labeling. We are supporting also Roles and Alias (only v2 configs). + + +___ + +
+ + +
+Scene Inventory tool: Refactor Scene Inventory tool (for AYON) #5758 + +Modified scene inventory tool for AYON. The main difference is in how project name is defined and replacement of assets combobox with folders dialog. + + +___ + +
+ + +
+AYON: Support dev bundles #5783 + +Modules can be loaded in AYON dev mode from different location. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Testing: Ingest Maya userSetup #5734 + +Suggesting to ingest `userSetup.py` startup script for easier collaboration and transparency of testing. + + +___ + +
+ + +
+Fusion: Work with pathmaps #5329 + +Path maps are a big part of our Fusion workflow. We map the project folder to a path map within Fusion so all loaders and savers point to the path map variable. This way any computer on any OS can open any comp no matter where the project folder is located. + + +___ + +
+ + +
+Maya: Add Maya 2024 and remove pre 2022. #5674 + +Adding Maya 2024 as default application variant.Removing Maya 2020 and older, as these are not supported anymore. + + +___ + +
+ + +
+Enhancement: Houdini: Allow using template keys in Houdini shelves manager #5727 + +Allow using Template keys in Houdini shelves manager. + + +___ + +
+ + +
+Houdini: Fix Show in usdview loader action #5737 + +Fix the "Show in USD View" loader to show up in Houdini + + +___ + +
+ + +
+Nuke: validator of asset context with repair actions #5749 + +Instance nodes with different context of asset and task can be now validated and repaired via repair action. + + +___ + +
+ + +
+AYON: Tools enhancements #5753 + +Few enhancements and tweaks of AYON related tools. + + +___ + +
+ + +
+Max: Tweaks on ValidateMaxContents #5759 + +This PR provides enhancements on ValidateMaxContent as follow: +- Rename `ValidateMaxContents` to `ValidateContainers` +- Add related families which are required to pass the validation(All families except `Render` as the render instance is the one which only allows empty container) + + +___ + +
+ + +
+Enhancement: Nuke refactor `SelectInvalidAction` #5762 + +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. + + +___ + +
+ + +
+Enhancement: Tweak logging for Nuke for artist facing reports #5763 + +Tweak logs that are not artist-facing to debug level + in some cases clarify what the logged value is. + + +___ + +
+ + +
+AYON Settings: Disk mapping #5786 + +Added disk mapping settings to core addon settings. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: add colorspace argument to redshiftTextureProcessor #5645 + +In color managed Maya, texture processing during Look Extraction wasn't passing texture colorspaces set on textures to `redshiftTextureProcessor` tool. This in effect caused this tool to produce non-zero exit code (even though the texture was converted into wrong colorspace) and therefor crash of the extractor. This PR is passing colorspace to that tool if color management is enabled. + + +___ + +
+ + +
+Maya: don't call `cmds.ogs()` in headless mode #5769 + +`cmds.ogs()` is a call that will crash if Maya is running in headless mode (mayabatch, mayapy). This is handling that case. + + +___ + +
+ + +
+Resolve: inventory management fix #5673 + +Loaded Timeline item containers are now updating correctly and version management is working as it suppose to. +- [x] updating loaded timeline items +- [x] Removing of loaded timeline items + + +___ + +
+ + +
+Blender: Remove 'update_hierarchy' #5756 + +Remove `update_hierarchy` function which is causing crashes in scene inventory tool. + + +___ + +
+ + +
+Max: bug fix on the settings in pointcloud family #5768 + +Bug fix on the settings being errored out in validate point cloud(see links:https://github.com/ynput/OpenPype/pull/5759#pullrequestreview-1676681705) and passibly in point cloud extractor. + + +___ + +
+ + +
+AYON settings: Fix default factory of tools #5773 + +Fix default factory of application tools. + + +___ + +
+ + +
+Fusion: added missing OPENPYPE_VERSION #5776 + +Fusion submission to Deadline was missing OPENPYPE_VERSION env var when submitting from build (not source code directly). This missing env var might break rendering on DL if path to OP executable (openpype_console.exe) is not set explicitly and might cause an issue when different versions of OP are deployed.This PR adds this environment variable. + + +___ + +
+ + +
+Ftrack: Skip tasks when looking for asset equivalent entity #5777 + +Skip tasks when looking for asset equivalent entity. + + +___ + +
+ + +
+Nuke: loading gizmos fixes #5779 + +Gizmo product is not offered in Loader as plugin. It is also updating as expected. + + +___ + +
+ + +
+General: thumbnail extractor as last extractor #5780 + +Fixing issue with the order of the `ExtractOIIOTranscode` and `ExtractThumbnail` plugins. The problem was that the `ExtractThumbnail` plugin was processed before the `ExtractOIIOTranscode` plugin. As a result, the `ExtractThumbnail` plugin did not inherit the `review` tag into the representation data. This caused the `ExtractThumbnail` plugin to fail in processing and creating thumbnails. + + +___ + +
+ + +
+Bug: fix key in application json #5787 + +In PR #5705 `maya` was wrongly used instead of `mayapy`, breaking AYON defaults in AYON Application Addon. + + +___ + +
+ + +
+'NumberAttrWidget' shows 'Multiselection' label on multiselection #5792 + +Attribute definition widget 'NumberAttrWidget' shows `< Multiselection >` label on multiselection. + + +___ + +
+ + +
+Publisher: Selection change by enabled checkbox on instance update attributes #5793 + +Change of instance by clicking on enabled checkbox will actually update attributes on right side to match the selection. + + +___ + +
+ + +
+Houdini: Remove `setParms` call since it's responsibility of `self.imprint` to set the values #5796 + +Revert a recent change made in #5621 due to this comment. However the change is faulty as can be seen mentioned here + + +___ + +
+ + +
+AYON loader: Fix SubsetLoader functionality #5799 + +Fix SubsetLoader plugin processing in AYON loader tool. + + +___ + +
+ +### **Merged pull requests** + + +
+Houdini: Add self publish button #5621 + +This PR allows single publishing by adding a publish button to created rop nodes in HoudiniAdmins are much welcomed to enable it from houdini general settingsPublish Button also includes all input publish instances. in this screen shot the alembic instance is ignored because the switch is turned off + + +___ + +
+ + +
+Nuke: fixing UNC support for OCIO path #5771 + +UNC paths were broken on windows for custom OCIO path and this is solving the issue with removed double slash at start of path + + +___ + +
+ + + + ## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2) diff --git a/openpype/version.py b/openpype/version.py index 6f740d0c78..ec09c45abb 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.3-nightly.2" +__version__ = "3.17.3" diff --git a/pyproject.toml b/pyproject.toml index ad93b70c0f..3803e4714e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.2" # OpenPype +version = "3.17.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 2962b0ae436f4d75f99bfe2c9f6f372e33effc4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 19 Oct 2023 13:01:19 +0000 Subject: [PATCH 0725/1224] 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 2849a4951a..d63d05f477 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 - 3.17.3-nightly.2 - 3.17.3-nightly.1 - 3.17.2 @@ -134,7 +135,6 @@ body: - 3.15.0-nightly.1 - 3.14.11-nightly.4 - 3.14.11-nightly.3 - - 3.14.11-nightly.2 validations: required: true - type: dropdown From 977d0144d83f5ae3f0f49a4052db022c4cdd6024 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 21:24:50 +0800 Subject: [PATCH 0726/1224] move render resolution function to lib --- openpype/hosts/max/api/lib.py | 20 +++++++++++++++++++ openpype/hosts/max/api/preview_animation.py | 22 +-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 166a66ce48..e6b669f82f 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -483,3 +483,23 @@ def get_plugins() -> list: plugin_info_list.append(plugin_info) return plugin_info_list + + +@contextlib.contextmanager +def render_resolution(width, height): + """Function to set render resolution option during + context + + Args: + width (int): render width + height (int): render height + """ + current_renderWidth = rt.renderWidth + current_renderHeight = rt.renderHeight + try: + rt.renderWidth = width + rt.renderHeight = height + yield + finally: + rt.renderWidth = current_renderWidth + rt.renderHeight = current_renderHeight diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 601ff65c81..3d66d278f0 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -2,7 +2,7 @@ import os import logging import contextlib from pymxs import runtime as rt -from .lib import get_max_version +from .lib import get_max_version, render_resolution log = logging.getLogger("openpype.hosts.max") @@ -24,26 +24,6 @@ def play_preview_when_done(has_autoplay): rt.preferences.playPreviewWhenDone = current_playback -@contextlib.contextmanager -def render_resolution(width, height): - """Function to set render resolution option during - context - - Args: - width (int): render width - height (int): render height - """ - current_renderWidth = rt.renderWidth - current_renderHeight = rt.renderHeight - try: - rt.renderWidth = width - rt.renderHeight = height - yield - finally: - rt.renderWidth = current_renderWidth - rt.renderHeight = current_renderHeight - - @contextlib.contextmanager def viewport_camera(camera): """Function to set viewport camera during context From f674b7b10835a1bd75ad91f76082f14afd4a9f20 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 21:35:33 +0800 Subject: [PATCH 0727/1224] hound --- openpype/hosts/max/api/lib.py | 1 - openpype/hosts/max/api/preview_animation.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index e6b669f82f..5a54abd141 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 diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 3d66d278f0..caa4f60475 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -287,7 +287,7 @@ def viewport_options_for_preview_animation(): "dspBones": False, "dspBkg": True, "dspGrid": False, - "dspSafeFrame":False, + "dspSafeFrame": False, "dspFrameNums": False } else: From 71e9d6cc13c1b147abd67213d4d1ae234842a1f8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 19 Oct 2023 22:53:56 +0800 Subject: [PATCH 0728/1224] rename render_preview_animation --- openpype/hosts/max/api/preview_animation.py | 2 +- .../hosts/max/plugins/publish/extract_review_animation.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index caa4f60475..260d18893e 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -213,7 +213,7 @@ def publish_preview_sequences(staging_dir, filename, rt.gc(delayed=True) -def publish_preview_animation( +def render_preview_animation( instance, staging_dir, ext, review_camera, startFrame=None, endFrame=None, diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index c308aadfdb..979cbc828c 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -2,7 +2,7 @@ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.max.api.preview_animation import ( - publish_preview_animation + render_preview_animation ) @@ -34,7 +34,7 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) resolution = instance.data.get("resolution", ()) - publish_preview_animation( + render_preview_animation( instance, staging_dir, ext, review_camera, startFrame=start, endFrame=end, From e24140715b386e34bbefdf6350d6f75c0c388e8e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 00:30:24 +0800 Subject: [PATCH 0729/1224] clean up code for preview animation --- openpype/hosts/max/api/preview_animation.py | 157 +++++++++--------- .../publish/extract_review_animation.py | 26 ++- 2 files changed, 90 insertions(+), 93 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 260d18893e..171d335ba4 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -92,96 +92,92 @@ def viewport_preference_setting(general_viewport, setattr(viewport_setting, key, value) -def publish_review_animation(instance, staging_dir, start, - end, ext, fps, viewport_options): +def _render_preview_animation_max_2024( + filepath, start, end, ext, viewport_options): """Function to set up preview arguments in MaxScript. ****For 3dsMax 2024+ - Args: - instance (str): instance - filepath (str): output of the preview animation + filepath (str): filepath for render output without frame number and + extension, for example: /path/to/file start (int): startFrame end (int): endFrame - fps (float): fps value - viewport_options (dict): viewport setting options - + viewport_options (dict): viewport setting options, e.g. + {"vpStyle": "defaultshading", "vpPreset": "highquality"} Returns: - list: job arguments + list: Created files """ - job_args = list() - filename = "{0}..{1}".format(instance.name, ext) - filepath = os.path.join(staging_dir, filename) filepath = filepath.replace("\\", "/") + filepath = f"{filepath}..{ext}" + frame_template = f"{filepath}.{{:04d}}.{ext}" + job_args = list() default_option = f'CreatePreview filename:"{filepath}"' job_args.append(default_option) - frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa + frame_option = f"outputAVI:false start:{start} end:{end}" job_args.append(frame_option) - for key, value in viewport_options.items(): if isinstance(value, bool): if value: job_args.append(f"{key}:{value}") - elif isinstance(value, str): if key == "vpStyle": - if viewport_options[key] == "Realistic": + if value == "Realistic": value = "defaultshading" - elif viewport_options[key] == "Shaded": + elif value == "Shaded": log.warning( "'Shaded' Mode not supported in " - "preview animation in Max 2024..\n" - "Using 'defaultshading' instead") + "preview animation in Max 2024.\n" + "Using 'defaultshading' instead.") value = "defaultshading" - elif viewport_options[key] == "ConsistentColors": + elif value == "ConsistentColors": value = "flatcolor" else: value = value.lower() elif key == "vpPreset": - if viewport_options[key] == "Quality": + if value == "Quality": value = "highquality" - elif viewport_options[key] == "Customize": + elif value == "Customize": value = "userdefined" else: value = value.lower() job_args.append(f"{key}: #{value}") - auto_play_option = "autoPlay:false" job_args.append(auto_play_option) - job_str = " ".join(job_args) log.debug(job_str) - - return job_str + rt.completeRedraw() + rt.execute(job_str) + # Return the created files + return [frame_template.format(frame) for frame in range(start, end + 1)] -def publish_preview_sequences(staging_dir, filename, - startFrame, endFrame, - percentSize, ext): - """publish preview animation by creating bitmaps +def _render_preview_animation_max_pre_2024( + filepath, startFrame, endFrame, percentSize, ext): + """Render viewport animation by creating bitmaps ***For 3dsMax Version <2024 - Args: - staging_dir (str): staging directory - filename (str): filename + filepath (str): filepath without frame numbers and extension startFrame (int): start frame endFrame (int): end frame percentSize (int): percentage of the resolution ext (str): image extension + Returns: + list: Created filepaths """ # get the screenshot resolution_percentage = float(percentSize) / 100 res_width = rt.renderWidth * resolution_percentage res_height = rt.renderHeight * resolution_percentage - viewportRatio = float(res_width / res_height) - - for i in range(startFrame, endFrame + 1): - rt.sliderTime = i - fname = "{}.{:04}.{}".format(filename, i, ext) - filepath = os.path.join(staging_dir, fname) - filepath = filepath.replace("\\", "/") + frame_template = "{}.{{:04}}.{}".format(filepath, ext) + frame_template.replace("\\", "/") + files = [] + user_cancelled = False + for frame in range(startFrame, endFrame + 1): + rt.sliderTime = frame + filepath = frame_template.format(frame) preview_res = rt.bitmap( - res_width, res_height, filename=filepath) + res_width, res_height, filename=filepath + ) dib = rt.gw.getViewportDib() dib_width = float(dib.width) dib_height = float(dib.height) @@ -197,71 +193,78 @@ def publish_preview_sequences(staging_dir, filename, tempImage_bmp = rt.bitmap(widthCrop, dib_height) src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height) rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) - # copy the bitmap and close it rt.copy(tempImage_bmp, preview_res) rt.close(tempImage_bmp) - rt.save(preview_res) rt.close(preview_res) - rt.close(dib) - - if rt.keyboard.escPressed: - rt.exit() + files.append(filepath) # clean up the cache + if rt.keyboard.escPressed: + user_cancelled = True + break rt.gc(delayed=True) + if user_cancelled: + raise RuntimeError("User cancelled rendering of viewport animation.") + return files def render_preview_animation( - instance, staging_dir, - ext, review_camera, - startFrame=None, endFrame=None, - resolution=None, + filepath, + ext, + review_camera, + start_frame=None, + end_frame=None, + width=1920, + height=1080, viewport_options=None): """Render camera review animation - Args: - instance (pyblish.api.instance): Instance - filepath (str): filepath + filepath (str): filepath to render to, without frame number and + extension + ext (str): output file extension review_camera (str): viewport camera for preview render - startFrame (int): start frame - endFrame (int): end frame + start_frame (int): start frame + end_frame (int): end frame + width (int): render resolution width + height (int): render resolution height viewport_options (dict): viewport setting options + Returns: + list: Rendered output files """ + if start_frame is None: + start_frame = int(rt.animationRange.start) + if end_frame is None: + end_frame = int(rt.animationRange.end) - if startFrame is None: - startFrame = int(rt.animationRange.start) - if endFrame is None: - endFrame = int(rt.animationRange.end) if viewport_options is None: viewport_options = viewport_options_for_preview_animation() - if resolution is None: - resolution = (1920, 1080) with play_preview_when_done(False): with viewport_camera(review_camera): - width, height = resolution with render_resolution(width, height): if int(get_max_version()) < 2024: with viewport_preference_setting( viewport_options["general_viewport"], viewport_options["nitrous_viewport"], - viewport_options["vp_btn_mgr"]): + viewport_options["vp_btn_mgr"] + ): percentSize = viewport_options.get("percentSize", 100) - - publish_preview_sequences( - staging_dir, instance.name, - startFrame, endFrame, percentSize, ext) + return _render_preview_animation_max_pre_2024( + filepath, + start_frame, + end_frame, + percentSize, + ext + ) else: - fps = instance.data["fps"] - rt.completeRedraw() - preview_arg = publish_review_animation( - instance, staging_dir, - startFrame, endFrame, - ext, fps, viewport_options) - rt.execute(preview_arg) - - rt.completeRedraw() + return _render_preview_animation_max_2024( + filepath, + start_frame, + end_frame, + ext, + viewport_options + ) def viewport_options_for_preview_animation(): diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 979cbc828c..df1f2b4182 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -24,8 +24,6 @@ class ExtractReviewAnimation(publish.Extractor): end = int(instance.data["frameEnd"]) filepath = os.path.join(staging_dir, filename) filepath = filepath.replace("\\", "/") - filenames = self.get_files( - instance.name, start, end, ext) self.log.debug( "Writing Review Animation to" @@ -34,13 +32,18 @@ class ExtractReviewAnimation(publish.Extractor): review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) resolution = instance.data.get("resolution", ()) - render_preview_animation( - instance, staging_dir, - ext, review_camera, - startFrame=start, endFrame=end, - resolution=resolution, + files = render_preview_animation( + os.path.join(staging_dir, instance.name), + ext, + review_camera, + start, + end, + width=resolution[0], + height=resolution[1], viewport_options=viewport_options) + filenames = [os.path.basename(path) for path in files] + tags = ["review"] if not instance.data.get("keepImages"): tags.append("delete") @@ -63,12 +66,3 @@ class ExtractReviewAnimation(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) - - def get_files(self, filename, start, end, ext): - file_list = [] - for frame in range(int(start), int(end) + 1): - actual_name = "{}.{:04}.{}".format( - filename, frame, ext) - file_list.append(actual_name) - - return file_list From 4c390a62391c65ec9dd8e403686708e5ee1d3ebd Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 00:32:27 +0800 Subject: [PATCH 0730/1224] hound --- openpype/hosts/max/api/preview_animation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 171d335ba4..1d7211443a 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -1,4 +1,3 @@ -import os import logging import contextlib from pymxs import runtime as rt From 25311caace08d2b663cd07293bb8266d88685ea3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 19:42:17 +0300 Subject: [PATCH 0731/1224] add repait action to turn off use handles for the failed instance --- .../plugins/publish/validate_frame_range.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index b35ab62002..343cf3c5e4 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -1,11 +1,17 @@ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectInvalidAction import hou +class DisableUseAssetHandlesAction(RepairAction): + label = "Disable use asset handles" + icon = "mdi.toggle-switch-off" + + class ValidateFrameRange(pyblish.api.InstancePlugin): """Validate Frame Range. @@ -17,7 +23,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder - 0.1 hosts = ["houdini"] label = "Validate Frame Range" - actions = [SelectInvalidAction] + actions = [DisableUseAssetHandlesAction, SelectInvalidAction] def process(self, instance): @@ -60,3 +66,32 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): .format(instance.data) ) return [rop_node] + + @classmethod + def repair(cls, instance): + + if not cls.get_invalid(instance): + # Already fixed + print ("Not working") + return + + # Disable use asset handles + context = instance.context + create_context = context.data["create_context"] + instance_id = instance.data.get("instance_id") + if not instance_id: + cls.log.debug("'{}' must have instance id" + .format(instance)) + return + + created_instance = create_context.get_instance_by_id(instance_id) + if not instance_id: + cls.log.debug("Unable to find instance '{}' by id" + .format(instance)) + return + + created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa + + create_context.save_changes() + cls.log.debug("use asset handles is turned off for '{}'" + .format(instance)) From 82edc833390d686fcaf6a8827615a22034c0d3fa Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 19 Oct 2023 19:43:11 +0300 Subject: [PATCH 0732/1224] resolve hound --- openpype/hosts/houdini/plugins/publish/validate_frame_range.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 343cf3c5e4..6a66f3de9f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -72,7 +72,6 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): if not cls.get_invalid(instance): # Already fixed - print ("Not working") return # Disable use asset handles From 32501fbb2bbb44fe9751c6420a3c37f440b98620 Mon Sep 17 00:00:00 2001 From: jmichael7 <148431692+jmichael7@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:46:54 +0530 Subject: [PATCH 0733/1224] Corrected a typo in Readme.md (Top -> To) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce98f845e6..ed3e058002 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ arguments and it will create zip file that OpenPype can use. Building documentation ---------------------- -Top build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation +To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation from current sources in `.\docs\build`. **Note that it needs existing virtual environment.** From 404a4dedfcfd798cbcdbab8dc3637d36b5e059e8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 11:20:58 +0800 Subject: [PATCH 0734/1224] clean up the code across the tycache families --- .../hosts/max/plugins/load/load_tycache.py | 2 +- .../max/plugins/publish/extract_tycache.py | 6 ++--- .../plugins/publish/validate_tyflow_data.py | 25 ++++++++----------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index f878ed9f1c..ff9598b33f 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -25,7 +25,7 @@ class TyCacheLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): """Load tyCache""" from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) + filepath = self.filepath_from_context(context) obj = rt.tyCache() obj.filename = filepath diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index d9d7c17cff..baed8a9e44 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -8,8 +8,7 @@ from openpype.pipeline import publish class ExtractTyCache(publish.Extractor): - """ - Extract tycache format with tyFlow operators. + """Extract tycache format with tyFlow operators. Notes: - TyCache only works for TyFlow Pro Plugin. @@ -89,7 +88,6 @@ class ExtractTyCache(publish.Extractor): """ filenames = [] - # should we include frame 0 ? for frame in range(int(start_frame), int(end_frame) + 1): filename = f"{instance.name}__tyPart_{frame:05}.tyc" filenames.append(filename) @@ -146,7 +144,7 @@ class ExtractTyCache(publish.Extractor): opt_list = [] for member in members: obj = member.baseobject - # TODO: to see if it can be used maxscript instead + # TODO: see if it can use maxscript instead anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: sub_anim = rt.GetSubAnim(obj, anim_name) diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py index 67c35ec01c..c0f29422ec 100644 --- a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -4,8 +4,7 @@ from pymxs import runtime as rt class ValidateTyFlowData(pyblish.api.InstancePlugin): - """Validate that TyFlow plugins or - relevant operators being set correctly.""" + """Validate TyFlow plugins or relevant operators are set correctly.""" order = pyblish.api.ValidatorOrder families = ["pointcloud", "tycache"] @@ -31,9 +30,9 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): if invalid_object or invalid_operator: raise PublishValidationError( "issues occurred", - description="Container should only include tyFlow object\n " - "and tyflow operator 'Export Particle' should be in \n" - "the tyFlow editor") + description="Container should only include tyFlow object " + "and tyflow operator 'Export Particle' should be in " + "the tyFlow editor.") def get_tyflow_object(self, instance): """Get the nodes which are not tyFlow object(s) @@ -43,19 +42,17 @@ class ValidateTyFlowData(pyblish.api.InstancePlugin): instance (pyblish.api.Instance): instance Returns: - invalid(list): list of invalid nodes which are not - tyFlow object(s) and editable mesh(es). + list: invalid nodes which are not tyFlow + object(s) and editable mesh(es). """ - invalid = [] container = instance.data["instance_node"] self.log.debug(f"Validating tyFlow container for {container}") - selection_list = instance.data["members"] - for sel in selection_list: - if rt.ClassOf(sel) not in [rt.tyFlow, rt.Editable_Mesh]: - invalid.append(sel) - - return invalid + allowed_classes = [rt.tyFlow, rt.Editable_Mesh] + return [ + member for member in instance.data["members"] + if rt.ClassOf(member) not in allowed_classes + ] def get_tyflow_operator(self, instance): """Check if the Export Particle Operators in the node From ec70706ab5ef53d8d71a4e0802d77ac28f9707d2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 11:22:26 +0800 Subject: [PATCH 0735/1224] hound --- openpype/hosts/max/plugins/load/load_tycache.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index ff9598b33f..a860ecd357 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -1,5 +1,3 @@ -import os - from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( unique_namespace, From 8369dfddc98ec289c2daf5fa39b3f1af70532221 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 11:43:30 +0800 Subject: [PATCH 0736/1224] use asset entity data for get_frame_range --- openpype/hosts/max/api/lib.py | 18 ++++++++++++------ .../plugins/publish/validate_frame_range.py | 5 +++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8b70b3ced7..fcd21111fa 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -234,22 +234,28 @@ def reset_scene_resolution(): set_scene_resolution(width, height) -def get_frame_range() -> Union[Dict[str, Any], None]: +def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: """Get the current assets frame range and handles. + Args: + asset_doc (dict): Asset Entity Data + Returns: dict: with frame start, frame end, handle start, handle end. """ # Set frame start/end - asset = get_current_project_asset() - frame_start = asset["data"].get("frameStart") - frame_end = asset["data"].get("frameEnd") + if asset_doc is None: + asset_doc = get_current_project_asset() + + data = asset_doc["data"] + frame_start = data.get("frameStart") + frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: return - handle_start = asset["data"].get("handleStart", 0) - handle_end = asset["data"].get("handleEnd", 0) + handle_start = data.get("handleStart", 0) + handle_end = data.get("handleEnd", 0) return { "frameStart": frame_start, "frameEnd": frame_end, diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index b1e8aafbb7..1ca9761da6 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -37,10 +37,11 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, def process(self, instance): if not self.is_active(instance.data): - self.log.info("Skipping validation...") + self.log.debug("Skipping Validate Frame Range...") return - frame_range = get_frame_range() + frame_range = get_frame_range( + asset_doc=instance.data["assetEntity"]) inst_frame_start = instance.data.get("frameStart") inst_frame_end = instance.data.get("frameEnd") From 151881e1b3b5b13dab81a360d297bdf4491b73d2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 16:29:42 +0800 Subject: [PATCH 0737/1224] clean up code for review families --- openpype/hosts/max/api/preview_animation.py | 11 ++-- .../max/plugins/publish/collect_review.py | 16 +++-- .../publish/extract_review_animation.py | 15 ++--- .../max/plugins/publish/extract_thumbnail.py | 60 ++++++------------- .../publish/validate_resolution_setting.py | 4 +- 5 files changed, 37 insertions(+), 69 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 1d7211443a..4c878cc33a 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -106,10 +106,10 @@ def _render_preview_animation_max_2024( list: Created files """ filepath = filepath.replace("\\", "/") - filepath = f"{filepath}..{ext}" + preview_output = f"{filepath}..{ext}" frame_template = f"{filepath}.{{:04d}}.{ext}" job_args = list() - default_option = f'CreatePreview filename:"{filepath}"' + default_option = f'CreatePreview filename:"{preview_output}"' job_args.append(default_option) frame_option = f"outputAVI:false start:{start} end:{end}" job_args.append(frame_option) @@ -199,10 +199,10 @@ def _render_preview_animation_max_pre_2024( rt.close(preview_res) rt.close(dib) files.append(filepath) - # clean up the cache if rt.keyboard.escPressed: user_cancelled = True break + # clean up the cache rt.gc(delayed=True) if user_cancelled: raise RuntimeError("User cancelled rendering of viewport animation.") @@ -267,11 +267,10 @@ def render_preview_animation( def viewport_options_for_preview_animation(): - """ - Function to store the default data of viewport options + """Function to store the default data of viewport options + Returns: dict: viewport setting options - """ # viewport_options should be the dictionary if int(get_max_version()) < 2024: diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index cfd48edb15..8b782344eb 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -35,8 +35,8 @@ class CollectReview(pyblish.api.InstancePlugin, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], - "resolution": (creator_attrs["review_width"], - creator_attrs["review_height"]) + "review_width": creator_attrs["review_width"], + "review_height": creator_attrs["review_height"], } if int(get_max_version()) >= 2024: @@ -67,9 +67,6 @@ class CollectReview(pyblish.api.InstancePlugin, "dspFrameNums": attr_values.get("dspFrameNums") } else: - preview_data = {} - preview_data.update({ - "percentSize": creator_attrs["percentSize"]}) general_viewport = { "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid") @@ -79,10 +76,11 @@ class CollectReview(pyblish.api.InstancePlugin, "ViewportPreset": creator_attrs["viewportPreset"], "UseTextureEnabled": creator_attrs["vpTexture"] } - preview_data["general_viewport"] = general_viewport - preview_data["nitrous_viewport"] = nitrous_viewport - preview_data["vp_btn_mgr"] = { - "EnableButtons": False + preview_data = { + "percentSize": creator_attrs["percentSize"], + "general_viewport": general_viewport, + "nitrous_viewport": nitrous_viewport, + "vp_btn_mgr": {"EnableButtons": False} } # Enable ftrack functionality diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index df1f2b4182..8391346e40 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -19,27 +19,22 @@ class ExtractReviewAnimation(publish.Extractor): def process(self, instance): staging_dir = self.staging_dir(instance) ext = instance.data.get("imageFormat") - filename = "{0}..{1}".format(instance.name, ext) start = int(instance.data["frameStart"]) end = int(instance.data["frameEnd"]) - filepath = os.path.join(staging_dir, filename) - filepath = filepath.replace("\\", "/") - + filepath = os.path.join(staging_dir, instance.name) self.log.debug( - "Writing Review Animation to" - " '%s' to '%s'" % (filename, staging_dir)) + "Writing Review Animation to '{}'".format(filepath)) review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) - resolution = instance.data.get("resolution", ()) files = render_preview_animation( - os.path.join(staging_dir, instance.name), + filepath, ext, review_camera, start, end, - width=resolution[0], - height=resolution[1], + width=instance.data["review_width"], + height=instance.data["review_height"], viewport_options=viewport_options) filenames = [os.path.basename(path) for path in files] diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 890ee24f8e..fdedb3d0fc 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -1,20 +1,12 @@ import os import tempfile import pyblish.api -from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import ( - viewport_setup_updated, - viewport_setup, - get_max_version, - set_preview_arg -) - +from openpype.hosts.max.api.preview_animation import render_preview_animation class ExtractThumbnail(publish.Extractor): - """ - Extract Thumbnail for Review + """Extract Thumbnail for Review """ order = pyblish.api.ExtractorOrder @@ -29,36 +21,26 @@ class ExtractThumbnail(publish.Extractor): self.log.debug( f"Create temp directory {tmp_staging} for thumbnail" ) - fps = float(instance.data["fps"]) + ext = instance.data.get("imageFormat") frame = int(instance.data["frameStart"]) instance.context.data["cleanupFullPaths"].append(tmp_staging) - filename = "{name}_thumbnail..png".format(**instance.data) - filepath = os.path.join(tmp_staging, filename) - filepath = filepath.replace("\\", "/") - thumbnail = self.get_filename(instance.name, frame) + filepath = os.path.join(tmp_staging, instance.name) + + self.log.debug("Writing Thumbnail to '{}'".format(filepath)) - self.log.debug( - "Writing Thumbnail to" - " '%s' to '%s'" % (filename, tmp_staging)) review_camera = instance.data["review_camera"] - if int(get_max_version()) >= 2024: - with viewport_setup_updated(review_camera): - preview_arg = set_preview_arg( - instance, filepath, frame, frame, fps) - rt.execute(preview_arg) - else: - visual_style_preset = instance.data.get("visualStyleMode") - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - with viewport_setup( - viewport_setting, - visual_style_preset, - review_camera): - viewport_setting.VisualStyleMode = rt.Name( - visual_style_preset) - preview_arg = set_preview_arg( - instance, filepath, frame, frame, fps) - rt.execute(preview_arg) + viewport_options = instance.data.get("viewport_options", {}) + files = render_preview_animation( + filepath, + ext, + review_camera, + frame, + frame, + width=instance.data["review_width"], + height=instance.data["review_height"], + viewport_options=viewport_options) + + thumbnail = next(os.path.basename(path) for path in files) representation = { "name": "thumbnail", @@ -73,9 +55,3 @@ class ExtractThumbnail(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) - - def get_filename(self, filename, target_frame): - thumbnail_name = "{}_thumbnail.{:04}.png".format( - filename, target_frame - ) - return thumbnail_name diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py index 969db0da2d..7d91a7b991 100644 --- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py +++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py @@ -12,7 +12,7 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, """Validate the resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 - families = ["maxrender", "review"] + families = ["maxrender"] hosts = ["max"] label = "Validate Resolution Setting" optional = True @@ -21,7 +21,7 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return width, height = self.get_db_resolution(instance) - current_width = rt.renderwidth + current_width = rt.renderWidth current_height = rt.renderHeight if current_width != width and current_height != height: raise PublishValidationError("Resolution Setting " From 2b335af1080f4ad1357ca55ed4c936a332be9d3e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 17:22:51 +0800 Subject: [PATCH 0738/1224] make the calculation of the resolution with precent size more accurate and clean up the code on thumbnail extractor --- openpype/hosts/max/api/preview_animation.py | 13 ++++++------- .../max/plugins/publish/extract_thumbnail.py | 18 ++++++------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 4c878cc33a..15fef1b428 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -157,15 +157,14 @@ def _render_preview_animation_max_pre_2024( filepath (str): filepath without frame numbers and extension startFrame (int): start frame endFrame (int): end frame - percentSize (int): percentage of the resolution ext (str): image extension Returns: list: Created filepaths """ # get the screenshot - resolution_percentage = float(percentSize) / 100 - res_width = rt.renderWidth * resolution_percentage - res_height = rt.renderHeight * resolution_percentage + percent = percentSize / 100.0 + res_width = int(round(rt.renderWidth * percent)) + res_height = int(round(rt.renderHeight * percent)) viewportRatio = float(res_width / res_height) frame_template = "{}.{{:04}}.{}".format(filepath, ext) frame_template.replace("\\", "/") @@ -212,7 +211,7 @@ def _render_preview_animation_max_pre_2024( def render_preview_animation( filepath, ext, - review_camera, + camera, start_frame=None, end_frame=None, width=1920, @@ -223,7 +222,7 @@ def render_preview_animation( filepath (str): filepath to render to, without frame number and extension ext (str): output file extension - review_camera (str): viewport camera for preview render + camera (str): viewport camera for preview render start_frame (int): start frame end_frame (int): end frame width (int): render resolution width @@ -240,7 +239,7 @@ def render_preview_animation( if viewport_options is None: viewport_options = viewport_options_for_preview_animation() with play_preview_when_done(False): - with viewport_camera(review_camera): + with viewport_camera(camera): with render_resolution(width, height): if int(get_max_version()) < 2024: with viewport_preference_setting( diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index fdedb3d0fc..05a5156cd3 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -15,17 +15,11 @@ class ExtractThumbnail(publish.Extractor): families = ["review"] def process(self, instance): - # TODO: Create temp directory for thumbnail - # - this is to avoid "override" of source file - tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") - self.log.debug( - f"Create temp directory {tmp_staging} for thumbnail" - ) ext = instance.data.get("imageFormat") frame = int(instance.data["frameStart"]) - instance.context.data["cleanupFullPaths"].append(tmp_staging) - filepath = os.path.join(tmp_staging, instance.name) - + staging_dir = self.staging_dir(instance) + filepath = os.path.join( + staging_dir, f"{instance.name}_thumbnail") self.log.debug("Writing Thumbnail to '{}'".format(filepath)) review_camera = instance.data["review_camera"] @@ -34,8 +28,8 @@ class ExtractThumbnail(publish.Extractor): filepath, ext, review_camera, - frame, - frame, + start_frame=frame, + end_frame=frame, width=instance.data["review_width"], height=instance.data["review_height"], viewport_options=viewport_options) @@ -46,7 +40,7 @@ class ExtractThumbnail(publish.Extractor): "name": "thumbnail", "ext": "png", "files": thumbnail, - "stagingDir": tmp_staging, + "stagingDir": staging_dir, "thumbnail": True } From 6583525e429cc98e4ac3d81cf3bc0a397990cdd5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 20 Oct 2023 11:58:51 +0100 Subject: [PATCH 0739/1224] Add option to replace only selected actors --- openpype/hosts/unreal/api/pipeline.py | 33 ++++-- .../unreal/plugins/inventory/update_actors.py | 112 +++++++++++------- 2 files changed, 88 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 760e052a3e..35b218e629 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -654,13 +654,21 @@ def generate_sequence(h, h_dir): def _get_comps_and_assets( - component_class, asset_class, old_assets, new_assets + component_class, asset_class, old_assets, new_assets, selected ): eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) - comps = eas.get_all_level_actors_components() - components = [ - c for c in comps if isinstance(c, component_class) - ] + + components = [] + if selected: + sel_actors = eas.get_selected_level_actors() + for actor in sel_actors: + comps = actor.get_components_by_class(component_class) + components.extend(comps) + else: + comps = eas.get_all_level_actors_components() + components = [ + c for c in comps if isinstance(c, component_class) + ] # Get all the static meshes among the old assets in a dictionary with # the name as key @@ -681,14 +689,15 @@ def _get_comps_and_assets( return components, selected_old_assets, selected_new_assets -def replace_static_mesh_actors(old_assets, new_assets): +def replace_static_mesh_actors(old_assets, new_assets, selected): smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) static_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( unreal.StaticMeshComponent, unreal.StaticMesh, old_assets, - new_assets + new_assets, + selected ) for old_name, old_mesh in old_meshes.items(): @@ -701,12 +710,13 @@ def replace_static_mesh_actors(old_assets, new_assets): static_mesh_comps, old_mesh, new_mesh) -def replace_skeletal_mesh_actors(old_assets, new_assets): +def replace_skeletal_mesh_actors(old_assets, new_assets, selected): skeletal_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( unreal.SkeletalMeshComponent, unreal.SkeletalMesh, old_assets, - new_assets + new_assets, + selected ) for old_name, old_mesh in old_meshes.items(): @@ -720,12 +730,13 @@ def replace_skeletal_mesh_actors(old_assets, new_assets): comp.set_skeletal_mesh_asset(new_mesh) -def replace_geometry_cache_actors(old_assets, new_assets): +def replace_geometry_cache_actors(old_assets, new_assets, selected): geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets( unreal.GeometryCacheComponent, unreal.GeometryCache, old_assets, - new_assets + new_assets, + selected ) for old_name, old_mesh in old_caches.items(): diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py index 37777114e2..2b012cf22c 100644 --- a/openpype/hosts/unreal/plugins/inventory/update_actors.py +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -10,58 +10,78 @@ from openpype.hosts.unreal.api.pipeline import ( from openpype.pipeline import InventoryAction -class UpdateActors(InventoryAction): - """Update Actors in level to this version. +def update_assets(containers, selected): + allowed_families = ["model", "rig"] + + # Get all the containers in the Unreal Project + all_containers = ls() + + for container in containers: + container_dir = container.get("namespace") + if container.get("family") not in allowed_families: + unreal.log_warning( + f"Container {container_dir} is not supported.") + continue + + # Get all containers with same asset_name but different objectName. + # These are the containers that need to be updated in the level. + sa_containers = [ + i + for i in all_containers + if ( + i.get("asset_name") == container.get("asset_name") and + i.get("objectName") != container.get("objectName") + ) + ] + + asset_content = unreal.EditorAssetLibrary.list_assets( + container_dir, recursive=True, include_folder=False + ) + + # Update all actors in level + for sa_cont in sa_containers: + sa_dir = sa_cont.get("namespace") + old_content = unreal.EditorAssetLibrary.list_assets( + sa_dir, recursive=True, include_folder=False + ) + + if container.get("family") == "rig": + replace_skeletal_mesh_actors( + old_content, asset_content, selected) + replace_static_mesh_actors( + old_content, asset_content, selected) + elif container.get("family") == "model": + if container.get("loader") == "PointCacheAlembicLoader": + replace_geometry_cache_actors( + old_content, asset_content, selected) + else: + replace_static_mesh_actors( + old_content, asset_content, selected) + + unreal.EditorLevelLibrary.save_current_level() + + delete_previous_asset_if_unused(sa_cont, old_content) + + +class UpdateAllActors(InventoryAction): + """Update all the Actors in the current level to the version of the asset + selected in the scene manager. """ - label = "Update Actors in level to this version" + label = "Replace all Actors in level to this version" icon = "arrow-up" def process(self, containers): - allowed_families = ["model", "rig"] + update_assets(containers, False) - # Get all the containers in the Unreal Project - all_containers = ls() - for container in containers: - container_dir = container.get("namespace") - if container.get("family") not in allowed_families: - unreal.log_warning( - f"Container {container_dir} is not supported.") - continue +class UpdateSelectedActors(InventoryAction): + """Update only the selected Actors in the current level to the version + of the asset selected in the scene manager. + """ - # Get all containers with same asset_name but different objectName. - # These are the containers that need to be updated in the level. - sa_containers = [ - i - for i in all_containers - if ( - i.get("asset_name") == container.get("asset_name") and - i.get("objectName") != container.get("objectName") - ) - ] + label = "Replace selected Actors in level to this version" + icon = "arrow-up" - asset_content = unreal.EditorAssetLibrary.list_assets( - container_dir, recursive=True, include_folder=False - ) - - # Update all actors in level - for sa_cont in sa_containers: - sa_dir = sa_cont.get("namespace") - old_content = unreal.EditorAssetLibrary.list_assets( - sa_dir, recursive=True, include_folder=False - ) - - if container.get("family") == "rig": - replace_skeletal_mesh_actors(old_content, asset_content) - replace_static_mesh_actors(old_content, asset_content) - elif container.get("family") == "model": - if container.get("loader") == "PointCacheAlembicLoader": - replace_geometry_cache_actors( - old_content, asset_content) - else: - replace_static_mesh_actors(old_content, asset_content) - - unreal.EditorLevelLibrary.save_current_level() - - delete_previous_asset_if_unused(sa_cont, old_content) + def process(self, containers): + update_assets(containers, True) From 465101c6398fcc1fcd6cca0e5ecd278dae2a4640 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 20 Oct 2023 12:22:44 +0100 Subject: [PATCH 0740/1224] Added another action to delete unused assets --- openpype/hosts/unreal/api/pipeline.py | 2 +- .../plugins/inventory/delete_unused_assets.py | 31 +++++++++++++++++++ .../unreal/plugins/inventory/update_actors.py | 4 +-- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 35b218e629..0bb19ec601 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -750,7 +750,7 @@ def replace_geometry_cache_actors(old_assets, new_assets, selected): comp.set_geometry_cache(new_mesh) -def delete_previous_asset_if_unused(container, asset_content): +def delete_asset_if_unused(container, asset_content): ar = unreal.AssetRegistryHelpers.get_asset_registry() references = set() diff --git a/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py new file mode 100644 index 0000000000..f63476b3c7 --- /dev/null +++ b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py @@ -0,0 +1,31 @@ +import unreal + +from openpype.hosts.unreal.api.pipeline import delete_asset_if_unused +from openpype.pipeline import InventoryAction + + + +class DeleteUnusedAssets(InventoryAction): + """Delete all the assets that are not used in any level. + """ + + label = "Delete Unused Assets" + icon = "trash" + color = "red" + order = 1 + + def process(self, containers): + allowed_families = ["model", "rig"] + + for container in containers: + container_dir = container.get("namespace") + if container.get("family") not in allowed_families: + unreal.log_warning( + f"Container {container_dir} is not supported.") + continue + + asset_content = unreal.EditorAssetLibrary.list_assets( + container_dir, recursive=True, include_folder=False + ) + + delete_asset_if_unused(container, asset_content) diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py index 2b012cf22c..6bc576716c 100644 --- a/openpype/hosts/unreal/plugins/inventory/update_actors.py +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -5,7 +5,7 @@ from openpype.hosts.unreal.api.pipeline import ( replace_static_mesh_actors, replace_skeletal_mesh_actors, replace_geometry_cache_actors, - delete_previous_asset_if_unused, + delete_asset_if_unused, ) from openpype.pipeline import InventoryAction @@ -60,7 +60,7 @@ def update_assets(containers, selected): unreal.EditorLevelLibrary.save_current_level() - delete_previous_asset_if_unused(sa_cont, old_content) + delete_asset_if_unused(sa_cont, old_content) class UpdateAllActors(InventoryAction): From ea1c9bf70722ef075a02c446df2685e4dd50e00f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 Oct 2023 15:12:54 +0200 Subject: [PATCH 0741/1224] Removed redundant copy of extension.zxp (#5802) --- .../hosts/photoshop/api/extension/extension.zxp | Bin 54056 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/hosts/photoshop/api/extension/extension.zxp diff --git a/openpype/hosts/photoshop/api/extension/extension.zxp b/openpype/hosts/photoshop/api/extension/extension.zxp deleted file mode 100644 index 39b766cd0d354e63c5b0e5b874be47131860e3c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54056 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 Date: Fri, 20 Oct 2023 21:34:48 +0800 Subject: [PATCH 0742/1224] rename frame range data --- openpype/hosts/max/api/lib.py | 23 +++++++++--------- .../plugins/publish/collect_frame_range.py | 8 +++---- .../max/plugins/publish/collect_render.py | 4 ++-- .../max/plugins/publish/collect_review.py | 2 ++ .../max/plugins/publish/extract_camera_abc.py | 4 ++-- .../max/plugins/publish/extract_pointcache.py | 4 ++-- .../max/plugins/publish/extract_pointcloud.py | 4 ++-- .../plugins/publish/extract_redshift_proxy.py | 4 ++-- .../publish/extract_review_animation.py | 4 ++-- .../max/plugins/publish/extract_thumbnail.py | 2 +- .../plugins/publish/validate_frame_range.py | 24 +++++++++---------- 11 files changed, 42 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index fcd21111fa..979665d892 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -248,19 +248,19 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: asset_doc = get_current_project_asset() data = asset_doc["data"] - frame_start = data.get("frameStart") - frame_end = data.get("frameEnd") + frame_start = data.get("frameStart", 0) + frame_end = data.get("frameEnd", 0) if frame_start is None or frame_end is None: return handle_start = data.get("handleStart", 0) handle_end = data.get("handleEnd", 0) + frame_start_handle = int(frame_start) - int(handle_start) + frame_end_handle = int(frame_end) + int(handle_end) return { - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end + "frame_start_handle": frame_start_handle, + "frame_end_handle": frame_end_handle, } @@ -280,12 +280,11 @@ def reset_frame_range(fps: bool = True): fps_number = float(data_fps["data"]["fps"]) rt.frameRate = fps_number frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"]) - set_timeline(frame_start_handle, frame_end_handle) - set_render_frame_range(frame_start_handle, frame_end_handle) + + set_timeline( + frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + set_render_frame_range( + frame_range["frame_start_handle"], frame_range["frame_end_handle"]) def set_context_setting(): diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 2dd39b5b50..e83733e4f6 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -16,8 +16,8 @@ class CollectFrameRange(pyblish.api.InstancePlugin): def process(self, instance): if instance.data["family"] == "maxrender": - instance.data["frameStart"] = int(rt.rendStart) - instance.data["frameEnd"] = int(rt.rendEnd) + instance.data["frameStartHandle"] = int(rt.rendStart) + instance.data["frameEndHandle"] = int(rt.rendEnd) else: - instance.data["frameStart"] = int(rt.animationRange.start) - instance.data["frameEnd"] = int(rt.animationRange.end) + instance.data["frameStartHandle"] = int(rt.animationRange.start) + instance.data["frameEndHandle"] = int(rt.animationRange.end) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index a359e61921..fe580aafc8 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin): "renderer": renderer, "source": filepath, "plugin": "3dsmax", - "frameStart": int(rt.rendStart), - "frameEnd": int(rt.rendEnd), + "frameStart": instance.data.get("frameStartHandle"), + "frameEnd": instance.data.get("frameEndHandle"), "version": version_int, "farm": True } diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index cc4caae497..fd5bfddf20 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,6 +29,8 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, + "frameStart": instance.data.get("frameStartHandle"), + "frameEnd": instance.data.get("frameEndHandle"), "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index ea33bc67ed..a42f27be6e 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting Camera ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index a5480ff0dc..f6a8500c08 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor): families = ["pointcache"] def process(self, instance): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.debug("Extracting pointcache ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 79b4301377..d9fbe5e9dd 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -40,8 +40,8 @@ class ExtractPointCloud(publish.Extractor): def process(self, instance): self.settings = self.get_setting(instance) - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index 4f64e88584..47ed85977b 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 8e06e52b5c..af86ed7694 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -48,8 +48,8 @@ class ExtractReviewAnimation(publish.Extractor): "ext": instance.data["imageFormat"], "files": filenames, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": review_camera diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 82f4fc7a8b..0e7da89fa2 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -24,7 +24,7 @@ class ExtractThumbnail(publish.Extractor): f"Create temp directory {tmp_staging} for thumbnail" ) fps = int(instance.data["fps"]) - frame = int(instance.data["frameStart"]) + frame = int(instance.data["frameStartHandle"]) instance.context.data["cleanupFullPaths"].append(tmp_staging) filename = "{name}_thumbnail..png".format(**instance.data) filepath = os.path.join(tmp_staging, filename) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 1ca9761da6..fa1ff7e380 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -43,14 +43,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, frame_range = get_frame_range( asset_doc=instance.data["assetEntity"]) - inst_frame_start = instance.data.get("frameStart") - inst_frame_end = instance.data.get("frameEnd") - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) + inst_frame_start = instance.data.get("frameStartHandle") + inst_frame_end = instance.data.get("frameEndHandle") + frame_start_handle = frame_range["frame_start_handle"] + frame_end_handle = frame_range["frame_end_handle"] errors = [] if frame_start_handle != inst_frame_start: errors.append( @@ -63,10 +59,14 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, "from the asset data. ") if errors: - errors.append("You can use repair action to fix it.") - report = "Frame range settings are incorrect.\n\n" - for error in errors: - report += "- {}\n\n".format(error) + bullet_point_errors = "\n".join( + "- {}".format(error) for error in errors + ) + report = ( + "Frame range settings are incorrect.\n\n" + f"{bullet_point_errors}\n\n" + "You can use repair action to fix it." + ) raise PublishValidationError(report, title="Frame Range incorrect") @classmethod From 25c0c1996f7dccefcc6016c364d5d87e5cd65bd4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 Oct 2023 15:41:34 +0200 Subject: [PATCH 0743/1224] removed 'shotgun_api3' from openpype dependencies --- server_addon/openpype/client/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml index 6d5ac92ca7..40da8f6716 100644 --- a/server_addon/openpype/client/pyproject.toml +++ b/server_addon/openpype/client/pyproject.toml @@ -8,7 +8,6 @@ aiohttp_json_rpc = "*" # TVPaint server aiohttp-middlewares = "^2.0.0" wsrpc_aiohttp = "^3.1.1" # websocket server clique = "1.6.*" -shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"} gazu = "^0.9.3" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0" From 6453de982326f086e1d8dd1fda4d17ab0fc0fc7e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 22:11:11 +0800 Subject: [PATCH 0744/1224] align the frame range data with other hosts like Maya --- openpype/hosts/max/api/lib.py | 12 +++++++---- .../plugins/publish/validate_frame_range.py | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 979665d892..8aa38b013a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -259,8 +259,12 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: frame_start_handle = int(frame_start) - int(handle_start) frame_end_handle = int(frame_end) + int(handle_end) return { - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, + "frameStart": frame_start, + "frameEnd": frame_end, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStartHandle": frame_start_handle, + "frameEndHandle": frame_end_handle, } @@ -282,9 +286,9 @@ def reset_frame_range(fps: bool = True): frame_range = get_frame_range() set_timeline( - frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) set_render_frame_range( - frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) def set_context_setting(): diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index fa1ff7e380..0e8316e844 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -7,7 +7,8 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, - PublishValidationError + PublishValidationError, + KnownPublishError ) from openpype.hosts.max.api.lib import get_frame_range, set_timeline @@ -45,8 +46,13 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, inst_frame_start = instance.data.get("frameStartHandle") inst_frame_end = instance.data.get("frameEndHandle") - frame_start_handle = frame_range["frame_start_handle"] - frame_end_handle = frame_range["frame_end_handle"] + if inst_frame_start is None or inst_frame_end is None: + raise KnownPublishError( + "Missing frame start and frame end on " + "instance to to validate." + ) + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] errors = [] if frame_start_handle != inst_frame_start: errors.append( @@ -72,12 +78,9 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] + if instance.data["family"] == "maxrender": rt.rendStart = frame_start_handle rt.rendEnd = frame_end_handle From c5b63b241d199c87766f5a7d8a7c59946b8c9dd3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 22:12:20 +0800 Subject: [PATCH 0745/1224] check if frame start and frame end is None, if yes it will return empty dict --- openpype/hosts/max/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8aa38b013a..f62f580e83 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -248,11 +248,11 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: asset_doc = get_current_project_asset() data = asset_doc["data"] - frame_start = data.get("frameStart", 0) - frame_end = data.get("frameEnd", 0) + frame_start = data.get("frameStart") + frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: - return + return {} handle_start = data.get("handleStart", 0) handle_end = data.get("handleEnd", 0) From de2a6c33248b51f19faa51c74ab86fc935be6454 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 20 Oct 2023 16:03:44 +0100 Subject: [PATCH 0746/1224] Added dialog to confirm before deleting files --- .../plugins/inventory/delete_unused_assets.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py index f63476b3c7..8320e3c92d 100644 --- a/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py +++ b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py @@ -1,10 +1,10 @@ import unreal +from openpype.hosts.unreal.api.tools_ui import qt_app_context from openpype.hosts.unreal.api.pipeline import delete_asset_if_unused from openpype.pipeline import InventoryAction - class DeleteUnusedAssets(InventoryAction): """Delete all the assets that are not used in any level. """ @@ -14,7 +14,9 @@ class DeleteUnusedAssets(InventoryAction): color = "red" order = 1 - def process(self, containers): + dialog = None + + def _delete_unused_assets(self, containers): allowed_families = ["model", "rig"] for container in containers: @@ -29,3 +31,36 @@ class DeleteUnusedAssets(InventoryAction): ) delete_asset_if_unused(container, asset_content) + + def _show_confirmation_dialog(self, containers): + from qtpy import QtCore + from openpype.widgets import popup + from openpype.style import load_stylesheet + + dialog = popup.Popup() + dialog.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.WindowStaysOnTopHint + ) + dialog.setFocusPolicy(QtCore.Qt.StrongFocus) + dialog.setWindowTitle("Delete all unused assets") + dialog.setMessage( + "You are about to delete all the assets in the project that \n" + "are not used in any level. Are you sure you want to continue?" + ) + dialog.setButtonText("Delete") + + dialog.on_clicked.connect( + lambda: self._delete_unused_assets(containers) + ) + + dialog.show() + dialog.raise_() + dialog.activateWindow() + dialog.setStyleSheet(load_stylesheet()) + + self.dialog = dialog + + def process(self, containers): + with qt_app_context(): + self._show_confirmation_dialog(containers) From 69d665fd7d7809e9cab1941433627215956dd5fb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 23:03:52 +0800 Subject: [PATCH 0747/1224] make sure the collectorcorder fro collect render is later than collect frame range --- openpype/hosts/max/plugins/publish/collect_render.py | 6 +++--- openpype/hosts/max/plugins/publish/collect_review.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index fe580aafc8..7765b3b924 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -14,7 +14,7 @@ from openpype.client import get_last_version_by_subset_name class CollectRender(pyblish.api.InstancePlugin): """Collect Render for Deadline""" - order = pyblish.api.CollectorOrder + 0.01 + order = pyblish.api.CollectorOrder + 0.02 label = "Collect 3dsmax Render Layers" hosts = ['max'] families = ["maxrender"] @@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin): "renderer": renderer, "source": filepath, "plugin": "3dsmax", - "frameStart": instance.data.get("frameStartHandle"), - "frameEnd": instance.data.get("frameEndHandle"), + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "version": version_int, "farm": True } diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index fd5bfddf20..531521fa38 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,8 +29,8 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, - "frameStart": instance.data.get("frameStartHandle"), - "frameEnd": instance.data.get("frameEndHandle"), + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), From 0988f4a9a87d0f78f810b4a2dc6444784c50371c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 20 Oct 2023 16:09:54 +0100 Subject: [PATCH 0748/1224] Do not remove automatically unused assets --- openpype/hosts/unreal/plugins/inventory/update_actors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py index 6bc576716c..d32887b6f3 100644 --- a/openpype/hosts/unreal/plugins/inventory/update_actors.py +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -60,8 +60,6 @@ def update_assets(containers, selected): unreal.EditorLevelLibrary.save_current_level() - delete_asset_if_unused(sa_cont, old_content) - class UpdateAllActors(InventoryAction): """Update all the Actors in the current level to the version of the asset From 28c1c2dbb66a852850d351a4ebc9a35620846460 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 20 Oct 2023 16:10:43 +0100 Subject: [PATCH 0749/1224] Hound fixes --- openpype/hosts/unreal/plugins/inventory/update_actors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py index d32887b6f3..b0d941ba80 100644 --- a/openpype/hosts/unreal/plugins/inventory/update_actors.py +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -5,7 +5,6 @@ from openpype.hosts.unreal.api.pipeline import ( replace_static_mesh_actors, replace_skeletal_mesh_actors, replace_geometry_cache_actors, - delete_asset_if_unused, ) from openpype.pipeline import InventoryAction From 0240b2665d0a8ae446334d660cae9afaa64e7b74 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 Oct 2023 17:17:35 +0200 Subject: [PATCH 0750/1224] use context data instead of 'legacy_io' --- .../modules/timers_manager/plugins/publish/start_timer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/modules/timers_manager/plugins/publish/start_timer.py b/openpype/modules/timers_manager/plugins/publish/start_timer.py index 6408327ca1..19a67292f5 100644 --- a/openpype/modules/timers_manager/plugins/publish/start_timer.py +++ b/openpype/modules/timers_manager/plugins/publish/start_timer.py @@ -6,8 +6,6 @@ Requires: import pyblish.api -from openpype.pipeline import legacy_io - class StartTimer(pyblish.api.ContextPlugin): label = "Start Timer" @@ -25,9 +23,9 @@ class StartTimer(pyblish.api.ContextPlugin): self.log.debug("Publish is not affecting running timers.") return - project_name = legacy_io.active_project() - asset_name = legacy_io.Session.get("AVALON_ASSET") - task_name = legacy_io.Session.get("AVALON_TASK") + project_name = context.data["projectName"] + asset_name = context.data.get("asset") + task_name = context.data.get("task") if not project_name or not asset_name or not task_name: self.log.info(( "Current context does not contain all" From b5fc212933fa0e13a60f11a0baaf3eb4ebeae283 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 Oct 2023 17:23:29 +0200 Subject: [PATCH 0751/1224] removed unused `_asset` variable from `RenderInstance` --- openpype/pipeline/publish/abstract_collect_render.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index 8a26402bd8..764532cadb 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -11,7 +11,6 @@ import six import pyblish.api -from openpype.pipeline import legacy_io from .publish_plugins import AbstractMetaContextPlugin @@ -31,7 +30,7 @@ class RenderInstance(object): label = attr.ib() # label to show in GUI subset = attr.ib() # subset name task = attr.ib() # task name - asset = attr.ib() # asset name (AVALON_ASSET) + asset = attr.ib() # asset name attachTo = attr.ib() # subset name to attach render to setMembers = attr.ib() # list of nodes/members producing render output publish = attr.ib() # bool, True to publish instance @@ -129,7 +128,6 @@ class AbstractCollectRender(pyblish.api.ContextPlugin): """Constructor.""" super(AbstractCollectRender, self).__init__(*args, **kwargs) self._file_path = None - self._asset = legacy_io.Session["AVALON_ASSET"] self._context = None def process(self, context): From bf38b2bbefa47614af271ae7c68ee265c84701e2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 23:25:37 +0800 Subject: [PATCH 0752/1224] clean up the codes for collect frame range and get frame range function --- openpype/hosts/max/api/lib.py | 11 +++++++---- .../hosts/max/plugins/publish/collect_frame_range.py | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index f62f580e83..edbd14bb8b 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -254,10 +254,13 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: if frame_start is None or frame_end is None: return {} - handle_start = data.get("handleStart", 0) - handle_end = data.get("handleEnd", 0) - frame_start_handle = int(frame_start) - int(handle_start) - frame_end_handle = int(frame_end) + int(handle_end) + frame_start = int(frame_start) + frame_end = int(frame_end) + handle_start = int(data.get("handleStart", 0)) + handle_end = int(data.get("handleEnd", 0)) + frame_start_handle = frame_start - handle_start + frame_end_handle = frame_end + handle_end + return { "frameStart": frame_start, "frameEnd": frame_end, diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index e83733e4f6..86fb6e856c 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -"""Collect instance members.""" import pyblish.api from pymxs import runtime as rt From caf1cc5cc258fedd2dbcc3fc58e79a7f6acf489c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 21 Oct 2023 03:24:31 +0000 Subject: [PATCH 0753/1224] [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 ec09c45abb..e2e3c663af 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.3" +__version__ = "3.17.4-nightly.1" From 085398d14c87f3f1882e0fbc3ca40191f6b3241f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Oct 2023 03:25:11 +0000 Subject: [PATCH 0754/1224] 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 d63d05f477..3c126048da 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.4-nightly.1 - 3.17.3 - 3.17.3-nightly.2 - 3.17.3-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.0 - 3.15.0-nightly.1 - 3.14.11-nightly.4 - - 3.14.11-nightly.3 validations: required: true - type: dropdown From 39b44e126450f5c12ba18f3db6a13485a507b259 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 23 Oct 2023 16:07:18 +0800 Subject: [PATCH 0755/1224] fix the indentation --- openpype/hosts/max/plugins/publish/extract_tycache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py index baed8a9e44..9bfe74f679 100644 --- a/openpype/hosts/max/plugins/publish/extract_tycache.py +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -144,7 +144,7 @@ class ExtractTyCache(publish.Extractor): opt_list = [] for member in members: obj = member.baseobject - # TODO: see if it can use maxscript instead + # TODO: see if it can use maxscript instead anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: sub_anim = rt.GetSubAnim(obj, anim_name) From d9dd36d77aebf2714719dfeb64992cdb206243a5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 23 Oct 2023 17:24:18 +0800 Subject: [PATCH 0756/1224] clean up code & make sure the ext of thumbnail representation aligns with the image format data setting --- openpype/hosts/max/api/preview_animation.py | 3 --- openpype/hosts/max/plugins/publish/extract_thumbnail.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 15fef1b428..bb3ad4a7af 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -31,7 +31,6 @@ def viewport_camera(camera): camera (str): viewport camera for review render """ original = rt.viewport.getCamera() - has_autoplay = rt.preferences.playPreviewWhenDone if not original: # if there is no original camera # use the current camera as original @@ -39,11 +38,9 @@ def viewport_camera(camera): review_camera = rt.getNodeByName(camera) try: rt.viewport.setCamera(review_camera) - rt.preferences.playPreviewWhenDone = False yield finally: rt.viewport.setCamera(original) - rt.preferences.playPreviewWhenDone = has_autoplay @contextlib.contextmanager diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index fb391d09e1..e9d37d0be5 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -37,7 +37,7 @@ class ExtractThumbnail(publish.Extractor): representation = { "name": "thumbnail", - "ext": "png", + "ext": ext, "files": thumbnail, "stagingDir": staging_dir, "thumbnail": True From cc970effbc02f32bddff5f9c5f618c3ed7699a70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 Oct 2023 12:12:19 +0200 Subject: [PATCH 0757/1224] moved content of 'contants.py' to 'constants.py' --- openpype/pipeline/publish/constants.py | 4 ++++ openpype/pipeline/publish/contants.py | 3 --- openpype/pipeline/publish/lib.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 openpype/pipeline/publish/contants.py diff --git a/openpype/pipeline/publish/constants.py b/openpype/pipeline/publish/constants.py index dcd3445200..92e3fb089f 100644 --- a/openpype/pipeline/publish/constants.py +++ b/openpype/pipeline/publish/constants.py @@ -5,3 +5,7 @@ ValidatePipelineOrder = pyblish.api.ValidatorOrder + 0.05 ValidateContentsOrder = pyblish.api.ValidatorOrder + 0.1 ValidateSceneOrder = pyblish.api.ValidatorOrder + 0.2 ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3 + +DEFAULT_PUBLISH_TEMPLATE = "publish" +DEFAULT_HERO_PUBLISH_TEMPLATE = "hero" +TRANSIENT_DIR_TEMPLATE = "transient" diff --git a/openpype/pipeline/publish/contants.py b/openpype/pipeline/publish/contants.py deleted file mode 100644 index c5296afe9a..0000000000 --- a/openpype/pipeline/publish/contants.py +++ /dev/null @@ -1,3 +0,0 @@ -DEFAULT_PUBLISH_TEMPLATE = "publish" -DEFAULT_HERO_PUBLISH_TEMPLATE = "hero" -TRANSIENT_DIR_TEMPLATE = "transient" diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 4d9443f635..4ea2f932f1 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -25,7 +25,7 @@ from openpype.pipeline import ( ) from openpype.pipeline.plugin_discover import DiscoverResult -from .contants import ( +from .constants import ( DEFAULT_PUBLISH_TEMPLATE, DEFAULT_HERO_PUBLISH_TEMPLATE, TRANSIENT_DIR_TEMPLATE From 0b0e359632a9b89f804560e868aa9f15a2720281 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 23 Oct 2023 18:24:40 +0800 Subject: [PATCH 0758/1224] docstring tweak --- openpype/hosts/max/api/preview_animation.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index bb3ad4a7af..b8564a9bd4 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -8,8 +8,7 @@ log = logging.getLogger("openpype.hosts.max") @contextlib.contextmanager def play_preview_when_done(has_autoplay): - """Function to set preview playback option during - context + """Set preview playback option during context Args: has_autoplay (bool): autoplay during creating @@ -25,10 +24,10 @@ def play_preview_when_done(has_autoplay): @contextlib.contextmanager def viewport_camera(camera): - """Function to set viewport camera during context + """Set viewport camera during context ***For 3dsMax 2024+ Args: - camera (str): viewport camera for review render + camera (str): viewport camera """ original = rt.viewport.getCamera() if not original: @@ -90,7 +89,7 @@ def viewport_preference_setting(general_viewport, def _render_preview_animation_max_2024( filepath, start, end, ext, viewport_options): - """Function to set up preview arguments in MaxScript. + """Render viewport preview with MaxScript using `CreateAnimation`. ****For 3dsMax 2024+ Args: filepath (str): filepath for render output without frame number and @@ -263,7 +262,7 @@ def render_preview_animation( def viewport_options_for_preview_animation(): - """Function to store the default data of viewport options + """Get default viewport options for `render_preview_animation`. Returns: dict: viewport setting options From 113b0664ad7f86709b402de5b778df2f0b31050a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 23 Oct 2023 18:37:29 +0800 Subject: [PATCH 0759/1224] docstring tweak --- openpype/hosts/max/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 4515133d50..cbaf8a0c33 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -498,8 +498,7 @@ def get_plugins() -> list: @contextlib.contextmanager def render_resolution(width, height): - """Function to set render resolution option during - context + """Set render resolution option during context Args: width (int): render width From 8848f5ca2c5b7b554626e28a716aab8546bcb432 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 Oct 2023 15:26:07 +0200 Subject: [PATCH 0760/1224] removed unused 'get_render_path' function --- openpype/hosts/nuke/api/lib.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 734a565541..8b1ba0ab0d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -40,7 +40,6 @@ from openpype.settings import ( from openpype.modules import ModulesManager from openpype.pipeline.template_data import get_template_data_with_names from openpype.pipeline import ( - get_current_project_name, discover_legacy_creator_plugins, Anatomy, get_current_host_name, @@ -1099,26 +1098,6 @@ def check_subsetname_exists(nodes, subset_name): False) -def get_render_path(node): - ''' Generate Render path from presets regarding avalon knob data - ''' - avalon_knob_data = read_avalon_data(node) - - nuke_imageio_writes = get_imageio_node_setting( - node_class=avalon_knob_data["families"], - plugin_name=avalon_knob_data["creator"], - subset=avalon_knob_data["subset"] - ) - - data = { - "avalon": avalon_knob_data, - "nuke_imageio_writes": nuke_imageio_writes - } - - anatomy_filled = format_anatomy(data) - return anatomy_filled["render"]["path"].replace("\\", "/") - - def format_anatomy(data): ''' Helping function for formatting of anatomy paths From 473e09761bd5bb8b3b3de4677cb773a9189f57aa Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 23 Oct 2023 21:56:31 +0800 Subject: [PATCH 0761/1224] make sure percentSize works for adjusting resolution of preview animation --- openpype/hosts/max/api/preview_animation.py | 27 ++++++++++++------- .../max/plugins/publish/collect_review.py | 3 +-- .../publish/extract_review_animation.py | 1 + .../max/plugins/publish/extract_thumbnail.py | 1 + 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index b8564a9bd4..eb832c1d1c 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -88,7 +88,7 @@ def viewport_preference_setting(general_viewport, def _render_preview_animation_max_2024( - filepath, start, end, ext, viewport_options): + filepath, start, end, percentSize, ext, viewport_options): """Render viewport preview with MaxScript using `CreateAnimation`. ****For 3dsMax 2024+ Args: @@ -96,19 +96,25 @@ def _render_preview_animation_max_2024( extension, for example: /path/to/file start (int): startFrame end (int): endFrame + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x viewport_options (dict): viewport setting options, e.g. {"vpStyle": "defaultshading", "vpPreset": "highquality"} Returns: list: Created files """ + # the percentSize argument must be integer + percent = int(percentSize) filepath = filepath.replace("\\", "/") preview_output = f"{filepath}..{ext}" frame_template = f"{filepath}.{{:04d}}.{ext}" job_args = list() default_option = f'CreatePreview filename:"{preview_output}"' job_args.append(default_option) - frame_option = f"outputAVI:false start:{start} end:{end}" - job_args.append(frame_option) + output_res_option = f"outputAVI:false percentSize:{percent}" + job_args.append(output_res_option) + frame_range_options = f"start:{start} end:{end}" + job_args.append(frame_range_options) for key, value in viewport_options.items(): if isinstance(value, bool): if value: @@ -153,6 +159,8 @@ def _render_preview_animation_max_pre_2024( filepath (str): filepath without frame numbers and extension startFrame (int): start frame endFrame (int): end frame + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x ext (str): image extension Returns: list: Created filepaths @@ -210,6 +218,7 @@ def render_preview_animation( camera, start_frame=None, end_frame=None, + percentSize=100.0, width=1920, height=1080, viewport_options=None): @@ -221,6 +230,8 @@ def render_preview_animation( camera (str): viewport camera for preview render start_frame (int): start frame end_frame (int): end frame + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x width (int): render resolution width height (int): render resolution height viewport_options (dict): viewport setting options @@ -243,7 +254,6 @@ def render_preview_animation( viewport_options["nitrous_viewport"], viewport_options["vp_btn_mgr"] ): - percentSize = viewport_options.get("percentSize", 100) return _render_preview_animation_max_pre_2024( filepath, start_frame, @@ -256,6 +266,7 @@ def render_preview_animation( filepath, start_frame, end_frame, + percentSize, ext, viewport_options ) @@ -272,7 +283,6 @@ def viewport_options_for_preview_animation(): return { "visualStyleMode": "defaultshading", "viewportPreset": "highquality", - "percentSize": 100, "vpTexture": False, "dspGeometry": True, "dspShapes": False, @@ -288,18 +298,15 @@ def viewport_options_for_preview_animation(): } else: viewport_options = {} - viewport_options.update({"percentSize": 100}) - general_viewport = { + viewport_options["general_viewport"] = { "dspBkg": True, "dspGrid": False } - nitrous_viewport = { + viewport_options["nitrous_viewport"] = { "VisualStyleMode": "defaultshading", "ViewportPreset": "highquality", "UseTextureEnabled": False } - viewport_options["general_viewport"] = general_viewport - viewport_options["nitrous_viewport"] = nitrous_viewport viewport_options["vp_btn_mgr"] = { "EnableButtons": False} return viewport_options diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index ea606f8b9e..1f488f8180 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -32,6 +32,7 @@ class CollectReview(pyblish.api.InstancePlugin, "review_camera": camera_name, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], + "percentSize": creator_attrs["percentSize"], "imageFormat": creator_attrs["imageFormat"], "keepImages": creator_attrs["keepImages"], "fps": instance.context.data["fps"], @@ -52,7 +53,6 @@ class CollectReview(pyblish.api.InstancePlugin, preview_data = { "vpStyle": creator_attrs["visualStyleMode"], "vpPreset": creator_attrs["viewportPreset"], - "percentSize": creator_attrs["percentSize"], "vpTextures": creator_attrs["vpTexture"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), @@ -77,7 +77,6 @@ class CollectReview(pyblish.api.InstancePlugin, "UseTextureEnabled": creator_attrs["vpTexture"] } preview_data = { - "percentSize": creator_attrs["percentSize"], "general_viewport": general_viewport, "nitrous_viewport": nitrous_viewport, "vp_btn_mgr": {"EnableButtons": False} diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index ae7744ac19..99dc5c5cdc 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -33,6 +33,7 @@ class ExtractReviewAnimation(publish.Extractor): review_camera, start, end, + percentSize=instance.data["percentSize"], width=instance.data["review_width"], height=instance.data["review_height"], viewport_options=viewport_options) diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index e9d37d0be5..02fa75e032 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -29,6 +29,7 @@ class ExtractThumbnail(publish.Extractor): review_camera, start_frame=frame, end_frame=frame, + percentSize=instance.data["percentSize"], width=instance.data["review_width"], height=instance.data["review_height"], viewport_options=viewport_options) From e00e2ec271dafaf60ae8d9f37efc659231745db6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 Oct 2023 16:10:56 +0200 Subject: [PATCH 0762/1224] better origin data handling --- openpype/pipeline/create/context.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index f9e3f86652..3624c7155e 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -912,6 +912,12 @@ class CreatedInstance: # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + # Store original value of passed data self._orig_data = copy.deepcopy(data) @@ -919,10 +925,6 @@ class CreatedInstance: data.pop("family", None) data.pop("subset", None) - # Pop dictionary values that will be converted to objects to be able - # catch changes - orig_creator_attributes = data.pop("creator_attributes", None) or {} - orig_publish_attributes = data.pop("publish_attributes", None) or {} # QUESTION Does it make sense to have data stored as ordered dict? self._data = collections.OrderedDict() @@ -1039,7 +1041,10 @@ class CreatedInstance: @property def origin_data(self): - return copy.deepcopy(self._orig_data) + output = copy.deepcopy(self._orig_data) + output["creator_attributes"] = self.creator_attributes.origin_data + output["publish_attributes"] = self.publish_attributes.origin_data + return output @property def creator_identifier(self): @@ -1095,7 +1100,7 @@ class CreatedInstance: def changes(self): """Calculate and return changes.""" - return TrackChangesItem(self._orig_data, self.data_to_store()) + return TrackChangesItem(self.origin_data, self.data_to_store()) def mark_as_stored(self): """Should be called when instance data are stored. @@ -1211,7 +1216,7 @@ class CreatedInstance: publish_attributes = self.publish_attributes.serialize_attributes() return { "data": self.data_to_store(), - "orig_data": copy.deepcopy(self._orig_data), + "orig_data": self.origin_data, "creator_attr_defs": creator_attr_defs, "publish_attributes": publish_attributes, "creator_label": self._creator_label, @@ -1251,7 +1256,7 @@ class CreatedInstance: creator_identifier=creator_identifier, creator_label=creator_label, group_label=group_label, - creator_attributes=creator_attr_defs + creator_attr_defs=creator_attr_defs ) obj._orig_data = serialized_data["orig_data"] obj.publish_attributes.deserialize_attributes(publish_attributes) From d16cb8767122f04372d2aaa8b9bafb94bbee099d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 Oct 2023 16:11:13 +0200 Subject: [PATCH 0763/1224] mark instances as saved after save changes --- openpype/pipeline/create/context.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 3624c7155e..fcc3c248e9 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -2336,6 +2336,10 @@ class CreateContext: identifier, label, exc_info, add_traceback ) ) + else: + for update_data in update_list: + instance = update_data.instance + instance.mark_as_stored() if failed_info: raise CreatorsSaveFailed(failed_info) From 31b4b579763dd81ad02c5d33bb662176291d2ef0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 23 Oct 2023 16:47:52 +0200 Subject: [PATCH 0764/1224] fix 'mark_as_stored' on 'PublishAttributes' --- openpype/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index fcc3c248e9..25f03ddd3b 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -758,7 +758,7 @@ class PublishAttributes: yield name def mark_as_stored(self): - self._origin_data = copy.deepcopy(self._data) + self._origin_data = copy.deepcopy(self.data_to_store()) def data_to_store(self): """Convert attribute values to "data to store".""" From 171dedb4f30d20507d67ac62c7272dffe0555432 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 23 Oct 2023 18:46:53 +0300 Subject: [PATCH 0765/1224] update collector order --- .../houdini/plugins/publish/collect_rop_frame_range.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index a368e77ff7..23717561e2 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -13,7 +13,9 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, """Collect all frames which would be saved from the ROP nodes""" hosts = ["houdini"] - order = pyblish.api.CollectorOrder + # This specific order value is used so that + # this plugin runs after CollectAnatomyInstanceData + order = pyblish.api.CollectorOrder + 0.5 label = "Collect RopNode Frame Range" use_asset_handles = True @@ -32,7 +34,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) if attr_values.get("use_handles", self.use_asset_handles): - asset_data = instance.context.data["assetEntity"]["data"] + asset_data = instance.data["assetEntity"]["data"] handle_start = asset_data.get("handleStart", 0) handle_end = asset_data.get("handleEnd", 0) else: From 7bb81f7f6d712524c86c7bb414a0e8040132e104 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 23 Oct 2023 19:17:35 +0300 Subject: [PATCH 0766/1224] update collector order, follow BigRoys recommendation --- .../hosts/houdini/plugins/publish/collect_rop_frame_range.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 23717561e2..186244fedd 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -15,7 +15,7 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, hosts = ["houdini"] # This specific order value is used so that # this plugin runs after CollectAnatomyInstanceData - order = pyblish.api.CollectorOrder + 0.5 + order = pyblish.api.CollectorOrder + 0.499 label = "Collect RopNode Frame Range" use_asset_handles = True From 218c7f61c647159df119b19db381ed25b983de58 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 23 Oct 2023 19:23:00 +0300 Subject: [PATCH 0767/1224] update collector order for render product-types --- openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py | 4 +++- openpype/hosts/houdini/plugins/publish/collect_karma_rop.py | 4 +++- openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py | 4 +++- .../hosts/houdini/plugins/publish/collect_redshift_rop.py | 4 +++- openpype/hosts/houdini/plugins/publish/collect_vray_rop.py | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 28389c3b31..b489f83b29 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -20,7 +20,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Arnold ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index b66dcde13f..fe0b8711fc 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -24,7 +24,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Karma ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 3b7cf59f32..cc412f30a1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -24,7 +24,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Mantra ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index ca171a91f9..deb9eac971 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -24,7 +24,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Redshift ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index b1ff4c1886..53072aebc6 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -24,7 +24,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): """ label = "VRay ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["houdini"] families = ["vray_rop"] From 18eedd478eb14d9405ff4a22d32654189c6016e6 Mon Sep 17 00:00:00 2001 From: Mustafa Taher Date: Mon, 23 Oct 2023 23:04:59 +0300 Subject: [PATCH 0768/1224] Update doc string Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index eab77ca19a..41cb4c76ee 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -572,10 +572,14 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): """Get the frame data: start frame, end frame, steps, start frame with start handle and end frame with end handle. - This function uses Houdini node as the source of truth - therefore users are allowed to publish their desired frame range. + This function uses Houdini node's `trange`, `t1, `t2` and `t3` + parameters as the source of truth for the full inclusive frame + range to render, as such these are considered as the frame + range including the handles. - It also calculates frame start and end with handles. + The non-inclusive frame start and frame end without handles + are computed by subtracting the handles from the inclusive + frame range. Args: node(hou.Node) From 3761e3fc9a05a2fc978b6e34d696887fcf9e7a21 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 23 Oct 2023 23:06:43 +0300 Subject: [PATCH 0769/1224] resolve hound --- openpype/hosts/houdini/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 41cb4c76ee..115f4f3c17 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -572,9 +572,9 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): """Get the frame data: start frame, end frame, steps, start frame with start handle and end frame with end handle. - This function uses Houdini node's `trange`, `t1, `t2` and `t3` - parameters as the source of truth for the full inclusive frame - range to render, as such these are considered as the frame + This function uses Houdini node's `trange`, `t1, `t2` and `t3` + parameters as the source of truth for the full inclusive frame + range to render, as such these are considered as the frame range including the handles. The non-inclusive frame start and frame end without handles From a65a275c5fb06d411482577da825b0eeb1bfc7f3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 23 Oct 2023 23:10:43 +0300 Subject: [PATCH 0770/1224] update doc string --- openpype/hosts/houdini/api/lib.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 115f4f3c17..cadeaa8ed4 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -582,10 +582,12 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): frame range. Args: - node(hou.Node) - handle_start(int) - handle_end(int) - log(logging.Logger) + node (hou.Node): ROP node to retrieve frame range from, + the frame range is assumed to be the frame range + *including* the start and end handles. + handle_start (int): Start handles. + handle_end (int): End handles. + log (logging.Logger): Logger to log to. Returns: dict: frame data for start, end, steps, From 75864eee2130068092eac5da6cb8a08ed373819c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 24 Oct 2023 12:55:18 +0800 Subject: [PATCH 0771/1224] clean up the job_argument code for max 2024 --- openpype/hosts/max/api/preview_animation.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index eb832c1d1c..1bf99b86d0 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -108,13 +108,7 @@ def _render_preview_animation_max_2024( filepath = filepath.replace("\\", "/") preview_output = f"{filepath}..{ext}" frame_template = f"{filepath}.{{:04d}}.{ext}" - job_args = list() - default_option = f'CreatePreview filename:"{preview_output}"' - job_args.append(default_option) - output_res_option = f"outputAVI:false percentSize:{percent}" - job_args.append(output_res_option) - frame_range_options = f"start:{start} end:{end}" - job_args.append(frame_range_options) + job_args = [] for key, value in viewport_options.items(): if isinstance(value, bool): if value: @@ -141,10 +135,13 @@ def _render_preview_animation_max_2024( else: value = value.lower() job_args.append(f"{key}: #{value}") - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) - job_str = " ".join(job_args) - log.debug(job_str) + + job_str = ( + f'CreatePreview filename:"{preview_output}" outputAVI:false ' + f"percentSize:{percent} start:{start} end:{end} " + f"{' '.join(job_args)} " + "autoPlay:false" + ) rt.completeRedraw() rt.execute(job_str) # Return the created files From 4c837a6a9e0b859626cafe0e688572a5bb57175c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 24 Oct 2023 15:15:18 +0800 Subject: [PATCH 0772/1224] restore the os.path.normpath in loader for test --- openpype/hosts/max/plugins/load/load_tycache.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index a860ecd357..858297dd8e 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -1,3 +1,4 @@ +import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( unique_namespace, @@ -23,7 +24,7 @@ class TyCacheLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): """Load tyCache""" from pymxs import runtime as rt - filepath = self.filepath_from_context(context) + filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath @@ -46,8 +47,8 @@ class TyCacheLoader(load.LoaderPlugin): node_list = get_previous_loaded_object(node) update_custom_attribute_data(node, node_list) with maintained_selection(): - for prt in node_list: - prt.filename = path + for tyc in node_list: + tyc.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 8882eaf730ae91189ceae61b071187ba0e60d508 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 10:08:29 +0200 Subject: [PATCH 0773/1224] skip before window show in AYON mode --- openpype/hosts/blender/api/ops.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 0eb90eeff9..208c11cfe8 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -284,6 +284,8 @@ class LaunchLoader(LaunchQtApp): _tool_name = "loader" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.set_context( {"asset": get_current_asset_name()}, refresh=True @@ -309,6 +311,8 @@ class LaunchManager(LaunchQtApp): _tool_name = "sceneinventory" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.refresh() @@ -320,6 +324,8 @@ class LaunchLibrary(LaunchQtApp): _tool_name = "libraryloader" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.refresh() @@ -340,6 +346,8 @@ class LaunchWorkFiles(LaunchQtApp): return result def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.root = str(Path( os.environ.get("AVALON_WORKDIR", ""), os.environ.get("AVALON_SCENEDIR", ""), From 35a53598542d3233bbd2f3d46b37e1b92c02ed5a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 24 Oct 2023 16:18:14 +0800 Subject: [PATCH 0774/1224] docstring edit --- openpype/hosts/max/plugins/load/load_tycache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index 858297dd8e..41ea267c3d 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -13,7 +13,7 @@ from openpype.pipeline import get_representation_path, load class TyCacheLoader(load.LoaderPlugin): - """Point Cloud Loader.""" + """TyCache Loader.""" families = ["tycache"] representations = ["tyc"] From d66709791b4e663e8d2b6a3c435913eafc8d4245 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 24 Oct 2023 10:02:57 +0100 Subject: [PATCH 0775/1224] Include grease pencil in review and thumbnails --- openpype/hosts/blender/api/capture.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py index 849f8ee629..67c1523393 100644 --- a/openpype/hosts/blender/api/capture.py +++ b/openpype/hosts/blender/api/capture.py @@ -148,13 +148,14 @@ def applied_view(window, camera, isolate=None, options=None): area.ui_type = "VIEW_3D" - meshes = [obj for obj in window.scene.objects if obj.type == "MESH"] + types = ["MESH", "GPENCIL"] + objects = [obj for obj in window.scene.objects if obj.type in types] if camera == "AUTO": space.region_3d.view_perspective = "ORTHO" - isolate_objects(window, isolate or meshes) + isolate_objects(window, isolate or objects) else: - isolate_objects(window, isolate or meshes) + isolate_objects(window, isolate or objects) space.camera = window.scene.objects.get(camera) space.region_3d.view_perspective = "CAMERA" From bd545bce39b7d9925866f4d52fcd1f508770658c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 24 Oct 2023 10:27:25 +0100 Subject: [PATCH 0776/1224] Use set instead of list to check types Co-authored-by: Roy Nieterau --- openpype/hosts/blender/api/capture.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py index 67c1523393..bad6831143 100644 --- a/openpype/hosts/blender/api/capture.py +++ b/openpype/hosts/blender/api/capture.py @@ -148,7 +148,7 @@ def applied_view(window, camera, isolate=None, options=None): area.ui_type = "VIEW_3D" - types = ["MESH", "GPENCIL"] + types = {"MESH", "GPENCIL"} objects = [obj for obj in window.scene.objects if obj.type in types] if camera == "AUTO": From 1dfdeacd04721434231e2ab3f993a4715e8015da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 13:23:10 +0200 Subject: [PATCH 0777/1224] use 'open_current_requested' signal instead of missing 'save_as_requested' --- openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py index e59b319459..3a8e90f933 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -334,7 +334,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): def _on_mouse_double_click(self, event): if event.button() == QtCore.Qt.LeftButton: - self.save_as_requested.emit() + self.open_current_requested.emit() def _on_context_menu(self, point): index = self._view.indexAt(point) From 507adfa9368f24d9eaff47e5d386cad08671ab9e Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Mon, 5 Jun 2023 18:39:32 +0200 Subject: [PATCH 0778/1224] Refactor: Blender new publisher --- openpype/hosts/blender/api/ops.py | 6 ++++- openpype/hosts/blender/api/plugin.py | 25 ++++++++++++++++--- .../blender/plugins/create/create_action.py | 2 +- .../plugins/create/create_animation.py | 2 +- .../blender/plugins/create/create_camera.py | 2 +- .../blender/plugins/create/create_layout.py | 2 +- .../blender/plugins/create/create_model.py | 2 +- .../plugins/create/create_pointcache.py | 2 +- .../blender/plugins/create/create_review.py | 2 +- .../blender/plugins/create/create_rig.py | 2 +- .../blender/plugins/publish/extract_blend.py | 5 +++- 11 files changed, 39 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 208c11cfe8..22c590d4bd 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -275,6 +275,10 @@ class LaunchCreator(LaunchQtApp): def before_window_show(self): self._window.refresh() + def execute(self, context): + host_tools.show_publisher(tab="create") + return {"FINISHED"} + class LaunchLoader(LaunchQtApp): """Launch Avalon Loader.""" @@ -299,7 +303,7 @@ class LaunchPublisher(LaunchQtApp): bl_label = "Publish..." def execute(self, context): - host_tools.show_publish() + host_tools.show_publisher(tab="publish") return {"FINISHED"} diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index fb87d08cce..221c8d8936 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -6,7 +6,8 @@ from typing import Dict, List, Optional import bpy from openpype.pipeline import ( - LegacyCreator, + Creator, + CreatedInstance, LoaderPlugin, ) from .pipeline import AVALON_CONTAINERS @@ -134,10 +135,11 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class Creator(LegacyCreator): - """Base class for Creator plug-ins.""" +class BlenderCreator(Creator): + """Base class for Blender Creator plug-ins.""" defaults = ['Main'] + # Deprecated? def process(self): collection = bpy.data.collections.new(name=self.data["subset"]) bpy.context.scene.collection.children.link(collection) @@ -150,6 +152,23 @@ class Creator(LegacyCreator): return collection + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + """Override abstract method from Creator. + Create new instance and store it. + + Args: + subset_name(str): Subset name of created instance. + instance_data(dict): Base data for instance. + pre_create_data(dict): Data based on pre creation attributes. + Those may affect how creator works. + """ + instance = CreatedInstance( + self.family, subset_name, instance_data + ) + + class Loader(LoaderPlugin): """Base class for Loader plug-ins.""" diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 0203ba74c0..effbccd430 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -7,7 +7,7 @@ import openpype.hosts.blender.api.plugin from openpype.hosts.blender.api import lib -class CreateAction(openpype.hosts.blender.api.plugin.Creator): +class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): """Action output for character rigs""" name = "actionMain" diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index bc2840952b..1b9bbcacd9 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateAnimation(plugin.Creator): +class CreateAnimation(plugin.BlenderCreator): """Animation output for character rigs""" name = "animationMain" diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 7a770a3e77..c72f2b92ff 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateCamera(plugin.Creator): +class CreateCamera(plugin.BlenderCreator): """Polygonal static geometry""" name = "cameraMain" diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 73ed683256..ba75df6735 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateLayout(plugin.Creator): +class CreateLayout(plugin.BlenderCreator): """Layout output for character rigs""" name = "layoutMain" diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 51fc6683f6..a7e71622ea 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateModel(plugin.Creator): +class CreateModel(plugin.BlenderCreator): """Polygonal static geometry""" name = "modelMain" diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 65cf18472d..0555d956de 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreatePointcache(plugin.Creator): +class CreatePointcache(plugin.BlenderCreator): """Polygonal static geometry""" name = "pointcacheMain" diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 914f249891..58c26e0324 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateReview(plugin.Creator): +class CreateReview(plugin.BlenderCreator): """Single baked camera""" name = "reviewDefault" diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 08cc46ee3e..7a0393f0ba 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateRig(plugin.Creator): +class CreateRig(plugin.BlenderCreator): """Artist-friendly rig with controls to direct motion""" name = "rigMain" diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index d4f26b4f3c..fba9a861a0 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -5,7 +5,7 @@ import bpy from openpype.pipeline import publish -class ExtractBlend(publish.Extractor): +class ExtractBlend(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a blend file.""" label = "Extract Blend" @@ -16,6 +16,9 @@ class ExtractBlend(publish.Extractor): def process(self, instance): # Define extract output file path + if not self.is_active(instance.data): + return + stagingdir = self.staging_dir(instance) filename = f"{instance.name}.blend" filepath = os.path.join(stagingdir, filename) From e06dfbb8e8dbf1a92f2164a0b929968384d1aaff Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 6 Jun 2023 16:09:09 +0200 Subject: [PATCH 0779/1224] draft implementation for blender creator refactor --- openpype/hosts/blender/api/plugin.py | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 221c8d8936..39d0f5e662 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -152,6 +152,29 @@ class BlenderCreator(Creator): return collection + @staticmethod + def cache_subsets(shared_data): + """Cache instances for Creators shared data. + + Create `blender_cached_subsets` key when needed in shared data and + fill it with all collected instances from the scene under its + respective creator identifiers. + + If legacy instances are detected in the scene, create + `blender_cached_legacy_subsets` key and fill it with + all legacy subsets from this family as a value. # key or value? + + Args: + shared_data(Dict[str, Any]): Shared data. + + Return: + Dict[str, Any]: Shared data with cached subsets. + """ + if not shared_data.get('blender_cached_subsets'): + cache = {} + cache_legacy = {} + + def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): @@ -168,6 +191,58 @@ class BlenderCreator(Creator): self.family, subset_name, instance_data ) + collection = bpy.data.collections.new(name=self.data['subset']) + bpy.context.scene.collection.children.link(collection) + + if (self.options or {}).get("useSelection"): + for obj in get_selection(): + collection.objects.link(obj) + + + def collect_instances(self): + """Override abstract method from BaseCreator. + Collect existing instances related to this creator plugin.""" + for ( + instance_data in self.cache_subsets( + self.collection_shared_data + ).get('blender_cached_subsets') + ): + # Process only instances that were created by this creator + creator_id = instance_data.get('creator_identifier') + + if creator_id == self.identifier: + # Create instance object from existing data + instance = CreatedInstance.from_existing( + instance_data, self + ) + + # Add instance to create context + self.add_instance_to_context(instance) + + + def update_instances(self, update_list): + """Override abstract method from BaseCreator. + Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Changed instances + and their changes, as a list of tuples.""" + for created_instance, _changes in update_list: + data = created_instance.data_to_store() + + # TODO + + + def remove_instances(self, instances: List[CreatedInstance]): + """Override abstract method from BaseCreator. + Method called when instances are removed. + + Args: + instance(List[CreatedInstance]): Instance objects to remove. + """ + for instance in instances: + self._remove_instance_from_context(instance) + class Loader(LoaderPlugin): """Base class for Loader plug-ins.""" From 3c198694a9be7dace95e5e027b604f23e3f2bdfe Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 6 Jun 2023 18:03:02 +0200 Subject: [PATCH 0780/1224] blender creator cache subsets --- openpype/hosts/blender/api/plugin.py | 59 +++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 39d0f5e662..9a982c45e7 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -10,7 +10,11 @@ from openpype.pipeline import ( CreatedInstance, LoaderPlugin, ) -from .pipeline import AVALON_CONTAINERS +from .pipeline import ( + AVALON_CONTAINERS, + AVALON_INSTANCES, + AVALON_PROPERTY, +) from .ops import ( MainThreadItem, execute_in_main_thread @@ -174,6 +178,44 @@ class BlenderCreator(Creator): cache = {} cache_legacy = {} + avalon_instances = bpy.data.collections.get(AVALON_INSTANCES) + if avalon_instances: + for obj in bpy.data.collections.get(AVALON_INSTANCES).objects: + avalon_prop = obj.get(AVALON_PROPERTY, {}) + if avalon_prop.get('id') == 'pyblish.avalon.instance': + creator_id = avalon_prop.get('creator_identifier') + + if creator_id: + # Creator instance + cache.setdefault(creator_id, []).append( + avalon_prop + ) + else: + family = avalon_prop.get('family') + if family: + # Legacy creator instance + cache_legacy.setdefault(family, []).append( + avalon_prop + ) + + for col in bpy.data.collections: + avalon_prop = col.get(AVALON_PROPERTY, {}) + if avalon_prop.get('id') == 'pyblish.avalon.instance': + creaor_id = avalon_prop.get('creator_identifier') + + if creator_id: + # Creator instance + cache.setdefault(creator_id, []).append(avalon_prop) + else: + family = avalon_prop.get('family') + if family: + cache_legacy.setdefault(family, []) + if family: + # Legacy creator instance + cache_legacy.setdefault(family, []).append( + avalon_prop + ) + def create( self, subset_name: str, instance_data: dict, pre_create_data: dict @@ -202,11 +244,16 @@ class BlenderCreator(Creator): def collect_instances(self): """Override abstract method from BaseCreator. Collect existing instances related to this creator plugin.""" - for ( - instance_data in self.cache_subsets( - self.collection_shared_data - ).get('blender_cached_subsets') - ): + + # Cache subsets in shared data + self.cache_subsets(self.collection_shared_data) + + # Get cached subsets + cached_subsets = self.collection_shared_data.get('blender_cached_subsets') + if not cached_subsets: + return + + for instance_data in cached_subsets: # Process only instances that were created by this creator creator_id = instance_data.get('creator_identifier') From d4c030e77d38b7db3fc50c98d0d43ca6d6beae8e Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Fri, 16 Jun 2023 11:16:42 +0200 Subject: [PATCH 0781/1224] Add identifiers to blender creators. Fix wrong method name --- openpype/hosts/blender/api/plugin.py | 2 +- openpype/hosts/blender/plugins/create/create_action.py | 1 + openpype/hosts/blender/plugins/create/create_animation.py | 1 + openpype/hosts/blender/plugins/create/create_camera.py | 1 + openpype/hosts/blender/plugins/create/create_layout.py | 1 + openpype/hosts/blender/plugins/create/create_model.py | 1 + openpype/hosts/blender/plugins/create/create_pointcache.py | 1 + openpype/hosts/blender/plugins/create/create_review.py | 1 + openpype/hosts/blender/plugins/create/create_rig.py | 1 + 9 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 9a982c45e7..c13af363c5 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -264,7 +264,7 @@ class BlenderCreator(Creator): ) # Add instance to create context - self.add_instance_to_context(instance) + self._add_instance_to_context(instance) def update_instances(self, update_list): diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index effbccd430..5f4ded3688 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api import lib class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): """Action output for character rigs""" + identifier = "io.openpype.creators.blender.action" name = "actionMain" label = "Action" family = "action" diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 1b9bbcacd9..277c588610 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateAnimation(plugin.BlenderCreator): """Animation output for character rigs""" + identifier = "io.openpype.creators.blender.animation" name = "animationMain" label = "Animation" family = "animation" diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index c72f2b92ff..9086c44c5f 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateCamera(plugin.BlenderCreator): """Polygonal static geometry""" + identifier = "io.openpype.creators.blender.camera" name = "cameraMain" label = "Camera" family = "camera" diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index ba75df6735..ae567e6495 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateLayout(plugin.BlenderCreator): """Layout output for character rigs""" + identifier = "io.openpype.creators.blender.layout" name = "layoutMain" label = "Layout" family = "layout" diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index a7e71622ea..46196ab383 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateModel(plugin.BlenderCreator): """Polygonal static geometry""" + identifier = "io.openpype.creators.blender.model" name = "modelMain" label = "Model" family = "model" diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 0555d956de..4c434202c7 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreatePointcache(plugin.BlenderCreator): """Polygonal static geometry""" + identifier = "io.openpype.creators.blender.pointcache" name = "pointcacheMain" label = "Point Cache" family = "pointcache" diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 58c26e0324..87774aed7a 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateReview(plugin.BlenderCreator): """Single baked camera""" + identifier = "io.openpype.creators.blender.review" name = "reviewDefault" label = "Review" family = "review" diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 7a0393f0ba..84924a659b 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -10,6 +10,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateRig(plugin.BlenderCreator): """Artist-friendly rig with controls to direct motion""" + identifier = "io.openpype.creators.blender.rig" name = "rigMain" label = "Rig" family = "rig" From efa294defcc625acdd36e7948bde3f75b15beb88 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Fri, 16 Jun 2023 17:54:28 +0200 Subject: [PATCH 0782/1224] fixed wrong variable name --- openpype/hosts/blender/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index c13af363c5..02436e3583 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -201,7 +201,7 @@ class BlenderCreator(Creator): for col in bpy.data.collections: avalon_prop = col.get(AVALON_PROPERTY, {}) if avalon_prop.get('id') == 'pyblish.avalon.instance': - creaor_id = avalon_prop.get('creator_identifier') + creator_id = avalon_prop.get('creator_identifier') if creator_id: # Creator instance From b681173dc5b2ee34862ffa41dbc3fc452c682a56 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Mon, 3 Jul 2023 17:44:36 +0200 Subject: [PATCH 0783/1224] draft blenderhost class implementation --- openpype/hosts/blender/api/__init__.py | 2 + openpype/hosts/blender/api/pipeline.py | 41 ++++++++++++++++++- .../blender/blender_addon/startup/init.py | 4 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index e15f1193a5..ce2b444997 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -10,6 +10,7 @@ from .pipeline import ( ls, publish, containerise, + BlenderHost, ) from .plugin import ( @@ -47,6 +48,7 @@ __all__ = [ "ls", "publish", "containerise", + "BlenderHost", "Creator", "Loader", diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 84af0904f0..935981da86 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -6,10 +6,14 @@ from typing import Callable, Dict, Iterator, List, Optional import bpy from . import lib -from . import ops +from . import ops, properties import pyblish.api +from openpype.host import( + HostBase, + IPublishHost, +) from openpype.client import get_asset_by_name from openpype.pipeline import ( schema, @@ -47,6 +51,39 @@ IS_HEADLESS = bpy.app.background log = Logger.get_logger(__name__) +class BlenderHost(HostBase, IPublishHost): + name = "blender" + + def install(self): + """Override install method from HostBase. + Install Blender host functionality.""" + install() + + def ls(self) -> Iterator: + """List containers from active Blender scene.""" + return ls() + + def get_context_data(self): + """Override abstract method from IPublishHost. + Get global data related to creation-publishing from workfile. + + Returns: + dict: Context data stored using 'update_context_data'. + """ + return bpy.context.scene.openpype_context + + def update_context_data(self, data, changes): + """Override abstract method from IPublishHost. + Store global context data to workfile. + + Args: + data (dict): New data as are. + changes (dict): Only data that has been changed. Each value has + tuple with '(, )' value. + """ + bpy.context.scene.openpype_context.update(data) + + def pype_excepthook_handler(*args): traceback.print_exception(*args) @@ -72,6 +109,7 @@ def install(): if not IS_HEADLESS: ops.register() + properties.register() def uninstall(): @@ -86,6 +124,7 @@ def uninstall(): if not IS_HEADLESS: ops.unregister() + properties.unregister() def show_message(title, message): diff --git a/openpype/hosts/blender/blender_addon/startup/init.py b/openpype/hosts/blender/blender_addon/startup/init.py index 8dbff8a91d..603691675d 100644 --- a/openpype/hosts/blender/blender_addon/startup/init.py +++ b/openpype/hosts/blender/blender_addon/startup/init.py @@ -1,9 +1,9 @@ from openpype.pipeline import install_host -from openpype.hosts.blender import api +from openpype.hosts.blender.api import BlenderHost def register(): - install_host(api) + install_host(BlenderHost()) def unregister(): From 3b7ed1d71f86f6bd04029cc6ec0e369aff94c365 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 4 Jul 2023 10:55:04 +0200 Subject: [PATCH 0784/1224] Fix update_container_data error --- openpype/hosts/blender/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 935981da86..968c70089b 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -81,7 +81,7 @@ class BlenderHost(HostBase, IPublishHost): changes (dict): Only data that has been changed. Each value has tuple with '(, )' value. """ - bpy.context.scene.openpype_context.update(data) + bpy.context.scene.openpype_context |= data def pype_excepthook_handler(*args): From 7bdbf0252211167c065aa8f3e622d48e725c0bf8 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 4 Jul 2023 16:07:44 +0200 Subject: [PATCH 0785/1224] Added properties to blender host --- openpype/hosts/blender/api/properties.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 openpype/hosts/blender/api/properties.py diff --git a/openpype/hosts/blender/api/properties.py b/openpype/hosts/blender/api/properties.py new file mode 100644 index 0000000000..ffc1dea733 --- /dev/null +++ b/openpype/hosts/blender/api/properties.py @@ -0,0 +1,23 @@ +import bpy +from bpy.utils import register_classes_factory + +class OpenpypeContext(bpy.types.PropertyGroup): + pass + +classes = [OpenpypeContext] + +factory_register, factory_unregister = register_classes_factory(classes) + +def register(): + """Register the properties.""" + factory_register() + + bpy.types.Scene.openpype_context = bpy.props.CollectionProperty( + name="OpenPype Context", type=OpenpypeContext, options={"HIDDEN"} + ) + +def unregister(): + """Unregister the properties.""" + factory_unregister() + + del bpy.types.Scene.openpype_context From d9960e84d44dab94ac5fec6bf45315ce79ab5d37 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 4 Jul 2023 17:54:40 +0200 Subject: [PATCH 0786/1224] Fixed errors during create --- openpype/hosts/blender/api/pipeline.py | 2 +- openpype/hosts/blender/api/plugin.py | 6 +++--- openpype/hosts/blender/api/properties.py | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 968c70089b..935981da86 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -81,7 +81,7 @@ class BlenderHost(HostBase, IPublishHost): changes (dict): Only data that has been changed. Each value has tuple with '(, )' value. """ - bpy.context.scene.openpype_context |= data + bpy.context.scene.openpype_context.update(data) def pype_excepthook_handler(*args): diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 02436e3583..91068244c5 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -230,13 +230,13 @@ class BlenderCreator(Creator): Those may affect how creator works. """ instance = CreatedInstance( - self.family, subset_name, instance_data + self.family, subset_name, instance_data, self ) - collection = bpy.data.collections.new(name=self.data['subset']) + collection = bpy.data.collections.new(name=subset_name) bpy.context.scene.collection.children.link(collection) - if (self.options or {}).get("useSelection"): + if pre_create_data.get("useSelection"): for obj in get_selection(): collection.objects.link(obj) diff --git a/openpype/hosts/blender/api/properties.py b/openpype/hosts/blender/api/properties.py index ffc1dea733..c6b5ffe011 100644 --- a/openpype/hosts/blender/api/properties.py +++ b/openpype/hosts/blender/api/properties.py @@ -4,7 +4,7 @@ from bpy.utils import register_classes_factory class OpenpypeContext(bpy.types.PropertyGroup): pass -classes = [OpenpypeContext] +classes = [] # [OpenpypeContext] factory_register, factory_unregister = register_classes_factory(classes) @@ -12,9 +12,10 @@ def register(): """Register the properties.""" factory_register() - bpy.types.Scene.openpype_context = bpy.props.CollectionProperty( - name="OpenPype Context", type=OpenpypeContext, options={"HIDDEN"} - ) + bpy.types.Scene.openpype_context = {} + # bpy.types.Scene.openpype_context = bpy.props.CollectionProperty( + # name="OpenPype Context", type=OpenpypeContext, options={"HIDDEN"} + # ) def unregister(): """Unregister the properties.""" From cf66d12ef20feb2e1dcf326d4888434a3c506bdb Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 5 Jul 2023 17:36:42 +0200 Subject: [PATCH 0787/1224] Fixes to base creator, creators compatibility --- openpype/hosts/blender/api/plugin.py | 3 + .../blender/plugins/create/create_action.py | 27 +++++++++ .../plugins/create/create_animation.py | 47 +++++++++++++++- .../blender/plugins/create/create_camera.py | 56 ++++++++++++++++++- .../blender/plugins/create/create_layout.py | 45 ++++++++++++++- .../blender/plugins/create/create_model.py | 45 ++++++++++++++- .../plugins/create/create_pointcache.py | 22 ++++++++ .../blender/plugins/create/create_review.py | 43 +++++++++++++- .../blender/plugins/create/create_rig.py | 45 ++++++++++++++- 9 files changed, 315 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 91068244c5..153897cb9a 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -216,6 +216,8 @@ class BlenderCreator(Creator): avalon_prop ) + return shared_data + def create( self, subset_name: str, instance_data: dict, pre_create_data: dict @@ -232,6 +234,7 @@ class BlenderCreator(Creator): instance = CreatedInstance( self.family, subset_name, instance_data, self ) + self._add_instance_to_context(instance) collection = bpy.data.collections.new(name=subset_name) bpy.context.scene.collection.children.link(collection) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 5f4ded3688..32e924d758 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -16,6 +16,33 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): family = "action" icon = "male" + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + + name = openpype.hosts.blender.api.plugin.asset_name( + instance_data["asset"], subset_name + ) + collection = bpy.data.collections.new(name=name) + bpy.context.scene.collection.children.link(collection) + instance_data['task'] = get_current_task_name() + lib.imprint(collection, instance_data) + + if pre_create_data.get("useSelection"): + for obj in lib.get_selection(): + if (obj.animation_data is not None + and obj.animation_data.action is not None): + + empty_obj = bpy.data.objects.new(name=name, + object_data=None) + empty_obj.animation_data_create() + empty_obj.animation_data.action = obj.animation_data.action + empty_obj.animation_data.action.name = name + collection.objects.link(empty_obj) + + return collection + + # Deprecated def process(self): asset = self.data["asset"] diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 277c588610..12478fd7e5 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -16,12 +16,53 @@ class CreateAnimation(plugin.BlenderCreator): family = "animation" icon = "male" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + # name = self.name + # if not name: + name = plugin.asset_name(instance_data["asset"], subset_name) + # asset_group = bpy.data.objects.new(name=name, object_data=None) + # asset_group.empty_display_type = 'SINGLE_ARROW' + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + if pre_create_data.get("useSelection"): + selected = lib.get_selection() + for obj in selected: + asset_group.objects.link(obj) + elif pre_create_data.get("asset_group"): + obj = (self.options or {}).get("asset_group") + asset_group.objects.link(obj) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 9086c44c5f..6b9d1b7f73 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -16,12 +16,62 @@ class CreateCamera(plugin.BlenderCreator): family = "camera" icon = "video-camera" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + name = plugin.asset_name(instance_data["asset"], subset_name) + + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + if pre_create_data.get("useSelection"): + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + else: + plugin.deselect_all() + camera = bpy.data.cameras.new(subset_name) + camera_obj = bpy.data.objects.new(subset_name, camera) + + instances.objects.link(camera_obj) + + camera_obj.select_set(True) + asset_group.select_set(True) + bpy.context.view_layer.objects.active = asset_group + bpy.ops.object.parent_set(keep_transform=True) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index ae567e6495..24aa277349 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -16,12 +16,51 @@ class CreateLayout(plugin.BlenderCreator): family = "layout" icon = "cubes" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + name = plugin.asset_name(instance_data["asset"], subset_name) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + # Add selected objects to instance + if pre_create_data.get("useSelection"): + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 46196ab383..2e713dd661 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -16,12 +16,51 @@ class CreateModel(plugin.BlenderCreator): family = "model" icon = "cube" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + name = plugin.asset_name(instance_data["asset"], subset_name) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + # Add selected objects to instance + if pre_create_data.get("useSelection"): + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 4c434202c7..5932315bc8 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -16,6 +16,28 @@ class CreatePointcache(plugin.BlenderCreator): family = "pointcache" icon = "gears" + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + + name = openpype.hosts.blender.api.plugin.asset_name( + instance_data["asset"], subset_name + ) + collection = bpy.data.collections.new(name=name) + bpy.context.scene.collection.children.link(collection) + instance_data['task'] = get_current_task_name() + lib.imprint(collection, instance_data) + + if pre_create_data.get("useSelection"): + objects = lib.get_selection() + for obj in objects: + collection.objects.link(obj) + if obj.type == 'EMPTY': + objects.extend(obj.children) + + return collection + + # Deprecated def process(self): """ Run the creator on Blender main thread""" mti = ops.MainThreadItem(self._process) diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 87774aed7a..4b5cfb4c35 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -16,12 +16,49 @@ class CreateReview(plugin.BlenderCreator): family = "review" icon = "video-camera" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + name = plugin.asset_name(instance_data["asset"], subset_name) + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + if pre_create_data.get("useSelection"): + selected = lib.get_selection() + for obj in selected: + asset_group.objects.link(obj) + elif pre_create_data.get("asset_group"): + obj = (self.options or {}).get("asset_group") + asset_group.objects.link(obj) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 84924a659b..5ebf952d4a 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -16,12 +16,51 @@ class CreateRig(plugin.BlenderCreator): family = "rig" icon = "wheelchair" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # 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 + name = plugin.asset_name(instance_data["asset"], subset_name) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) + instance_data['task'] = get_current_task_name() + lib.imprint(asset_group, instance_data) + + # Add selected objects to instance + if pre_create_data.get("useSelection"): + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + + return asset_group + + # Deprecated + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process_legacy) + ops.execute_in_main_thread(mti) + + # Deprecated + def _process_legacy(self): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: From e0e7c965856795776e86cfab83a582b2fd4c134d Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Mon, 10 Jul 2023 14:59:31 +0200 Subject: [PATCH 0788/1224] blender creator instances added to context --- openpype/hosts/blender/plugins/create/create_action.py | 5 ++++- openpype/hosts/blender/plugins/create/create_animation.py | 6 +++++- openpype/hosts/blender/plugins/create/create_camera.py | 6 +++++- openpype/hosts/blender/plugins/create/create_layout.py | 6 +++++- openpype/hosts/blender/plugins/create/create_model.py | 6 +++++- openpype/hosts/blender/plugins/create/create_pointcache.py | 7 +++++-- openpype/hosts/blender/plugins/create/create_review.py | 6 +++++- openpype/hosts/blender/plugins/create/create_rig.py | 6 +++++- 8 files changed, 39 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 32e924d758..6c2c6d98ce 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance import openpype.hosts.blender.api.plugin from openpype.hosts.blender.api import lib @@ -19,6 +19,9 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) name = openpype.hosts.blender.api.plugin.asset_name( instance_data["asset"], subset_name diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 12478fd7e5..ec77569889 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateAnimation(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 6b9d1b7f73..55ea07d90f 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateCamera(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 24aa277349..60812d7fc9 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateLayout(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 2e713dd661..59bbae5bc4 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateModel(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 5932315bc8..bb843c5a22 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -19,8 +19,11 @@ class CreatePointcache(plugin.BlenderCreator): def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) - name = openpype.hosts.blender.api.plugin.asset_name( + name = plugin.asset_name( instance_data["asset"], subset_name ) collection = bpy.data.collections.new(name=name) diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 4b5cfb4c35..1191fb95f1 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateReview(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 5ebf952d4a..752f3f7b18 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -2,7 +2,7 @@ import bpy -from openpype.pipeline import get_current_task_name +from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES @@ -20,6 +20,10 @@ class CreateRig(plugin.BlenderCreator): self, subset_name: str, instance_data: dict, pre_create_data: dict ): """ Run the creator on Blender main thread""" + self._add_instance_to_context( + CreatedInstance(self.family, subset_name, instance_data, self) + ) + mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) From 6eae2c264d57b4ed5395de03950e09bc54ae7acb Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 12 Jul 2023 15:54:41 +0200 Subject: [PATCH 0789/1224] Add dict to imprint, type hints in blenderhost, imprint in create --- openpype/hosts/blender/api/lib.py | 2 +- openpype/hosts/blender/api/pipeline.py | 4 ++-- openpype/hosts/blender/api/plugin.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 9bb560c364..76bef09ceb 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -188,7 +188,7 @@ def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): # Support values evaluated at imprint value = value() - if not isinstance(value, (int, float, bool, str, list)): + if not isinstance(value, (int, float, bool, str, list, dict)): raise TypeError(f"Unsupported type: {type(value)}") imprint_data[key] = value diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 935981da86..4c06b1959e 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -63,7 +63,7 @@ class BlenderHost(HostBase, IPublishHost): """List containers from active Blender scene.""" return ls() - def get_context_data(self): + def get_context_data(self) -> dict: """Override abstract method from IPublishHost. Get global data related to creation-publishing from workfile. @@ -72,7 +72,7 @@ class BlenderHost(HostBase, IPublishHost): """ return bpy.context.scene.openpype_context - def update_context_data(self, data, changes): + def update_context_data(self, data: dict, changes: dict): """Override abstract method from IPublishHost. Store global context data to workfile. diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 153897cb9a..fe1e4ce81c 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -238,6 +238,7 @@ class BlenderCreator(Creator): collection = bpy.data.collections.new(name=subset_name) bpy.context.scene.collection.children.link(collection) + imprint(collection, instance_data) if pre_create_data.get("useSelection"): for obj in get_selection(): From 7617d1e0ad63042d39a3c626f6e614100d2bd879 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 19 Jul 2023 20:28:45 +0200 Subject: [PATCH 0790/1224] Set shared data, edit loop in cached_subsets --- openpype/hosts/blender/api/plugin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index fe1e4ce81c..97a3dfa6a6 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -216,6 +216,9 @@ class BlenderCreator(Creator): avalon_prop ) + shared_data["blender_cached_subsets"] = cache + shared_data["blender_cached_legacy_subsets"] = cache_legacy + return shared_data @@ -257,14 +260,17 @@ class BlenderCreator(Creator): if not cached_subsets: return - for instance_data in cached_subsets: + for instance_data in cached_subsets.get(self.identifier, []): # Process only instances that were created by this creator + data = dict() + for key, value in instance_data.items(): + data[key] = value creator_id = instance_data.get('creator_identifier') if creator_id == self.identifier: # Create instance object from existing data instance = CreatedInstance.from_existing( - instance_data, self + data, self ) # Add instance to create context From aeebd78cc71fb3edbe51157c60da849f62e727f3 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 20 Jul 2023 15:31:23 +0200 Subject: [PATCH 0791/1224] Collect instances functional --- openpype/hosts/blender/api/plugin.py | 4 ++-- openpype/hosts/blender/plugins/create/create_action.py | 3 +++ openpype/hosts/blender/plugins/create/create_animation.py | 3 +++ openpype/hosts/blender/plugins/create/create_camera.py | 3 +++ openpype/hosts/blender/plugins/create/create_layout.py | 3 +++ openpype/hosts/blender/plugins/create/create_model.py | 3 +++ openpype/hosts/blender/plugins/create/create_pointcache.py | 3 +++ openpype/hosts/blender/plugins/create/create_review.py | 3 +++ openpype/hosts/blender/plugins/create/create_rig.py | 3 +++ 9 files changed, 26 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 97a3dfa6a6..5dd352ff6c 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -262,10 +262,10 @@ class BlenderCreator(Creator): for instance_data in cached_subsets.get(self.identifier, []): # Process only instances that were created by this creator - data = dict() + data = {} for key, value in instance_data.items(): data[key] = value - creator_id = instance_data.get('creator_identifier') + creator_id = data.get('creator_identifier') if creator_id == self.identifier: # Create instance object from existing data diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 6c2c6d98ce..2c0d248994 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -29,6 +29,9 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index ec77569889..538c0455ac 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -47,6 +47,9 @@ class CreateAnimation(plugin.BlenderCreator): asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 55ea07d90f..ae85273353 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -45,6 +45,9 @@ class CreateCamera(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 60812d7fc9..cce422b229 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -44,6 +44,9 @@ class CreateLayout(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) # Add selected objects to instance diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 59bbae5bc4..57f9e79aa1 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -44,6 +44,9 @@ class CreateModel(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) # Add selected objects to instance diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index bb843c5a22..f95f79ae78 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -29,6 +29,9 @@ class CreatePointcache(plugin.BlenderCreator): collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 1191fb95f1..8472600c2f 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -43,6 +43,9 @@ class CreateReview(plugin.BlenderCreator): asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 752f3f7b18..a60f2a72ee 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -44,6 +44,9 @@ class CreateRig(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) instance_data['task'] = get_current_task_name() + instance_data['id'] = 'pyblish.avalon.instance' + instance_data['creator_identifier'] = self.identifier + instance_data['label'] = self.label lib.imprint(asset_group, instance_data) # Add selected objects to instance From 4d03c1c0e60b8e32248a4bb96a326c7c3a3762fb Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 20 Jul 2023 18:26:59 +0200 Subject: [PATCH 0792/1224] Set subset in inst data, use update to set it --- .../hosts/blender/plugins/create/create_action.py | 13 +++++++++---- .../blender/plugins/create/create_animation.py | 13 +++++++++---- .../hosts/blender/plugins/create/create_camera.py | 13 +++++++++---- .../hosts/blender/plugins/create/create_layout.py | 13 +++++++++---- .../hosts/blender/plugins/create/create_model.py | 13 +++++++++---- .../blender/plugins/create/create_pointcache.py | 13 +++++++++---- .../hosts/blender/plugins/create/create_review.py | 13 +++++++++---- openpype/hosts/blender/plugins/create/create_rig.py | 13 +++++++++---- 8 files changed, 72 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 2c0d248994..6951e86c46 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -28,10 +28,15 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): ) collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 538c0455ac..9e7dfbaf84 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -46,10 +46,15 @@ class CreateAnimation(plugin.BlenderCreator): # asset_group.empty_display_type = 'SINGLE_ARROW' asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index ae85273353..c5987779c0 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -44,10 +44,15 @@ class CreateCamera(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index cce422b229..cb61799f4a 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -43,10 +43,15 @@ class CreateLayout(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) # Add selected objects to instance diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 57f9e79aa1..ccf3668d98 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -43,10 +43,15 @@ class CreateModel(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) # Add selected objects to instance diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index f95f79ae78..e27cb22389 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -28,10 +28,15 @@ class CreatePointcache(plugin.BlenderCreator): ) collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 8472600c2f..afeaea951b 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -42,10 +42,15 @@ class CreateReview(plugin.BlenderCreator): name = plugin.asset_name(instance_data["asset"], subset_name) asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index a60f2a72ee..2db766f7ed 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -43,10 +43,15 @@ class CreateRig(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_data['task'] = get_current_task_name() - instance_data['id'] = 'pyblish.avalon.instance' - instance_data['creator_identifier'] = self.identifier - instance_data['label'] = self.label + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": self.label, + "task": get_current_task_name(), + "subset": subset_name, + } + ) lib.imprint(asset_group, instance_data) # Add selected objects to instance From 8236e46ca93b562feb66df51a81ec74c6785586f Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Mon, 31 Jul 2023 17:16:40 +0200 Subject: [PATCH 0793/1224] implement IWorkfileHost for Blender host --- openpype/hosts/blender/api/pipeline.py | 70 +++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 4c06b1959e..c3f8f06694 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -12,6 +12,7 @@ import pyblish.api from openpype.host import( HostBase, + IWorkfileHost, IPublishHost, ) from openpype.client import get_asset_by_name @@ -33,6 +34,14 @@ from openpype.lib import ( ) import openpype.hosts.blender from openpype.settings import get_project_settings +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) @@ -51,7 +60,7 @@ IS_HEADLESS = bpy.app.background log = Logger.get_logger(__name__) -class BlenderHost(HostBase, IPublishHost): +class BlenderHost(HostBase, IWorkfileHost, IPublishHost): name = "blender" def install(self): @@ -63,6 +72,65 @@ class BlenderHost(HostBase, IPublishHost): """List containers from active Blender scene.""" return ls() + def get_workfile_extensions(self) -> List[str]: + """Override get_workfile_extensions method from IWorkfileHost. + Get workfile possible extensions. + + Returns: + List[str]: Workfile extensions. + """ + return file_extensions() + + def save_workfile(self, dst_path: str = None): + """Override save_workfile method from IWorkfileHost. + Save currently opened workfile. + + Args: + dst_path (str): Where the current scene should be saved. Or use + current path if `None` is passed. + """ + save_file(dst_path if dst_path else bpy.data.filepath) + + def open_workfile(self, filepath: str): + """Override open_workfile method from IWorkfileHost. + Open workfile at specified filepath in the host. + + Args: + filepath (str): Path to workfile. + """ + open_file(filepath) + + def get_current_workfile(self) -> str: + """Override get_current_workfile method from IWorkfileHost. + Retrieve currently opened workfile path. + + Returns: + str: Path to currently opened workfile. + """ + return current_file() + + def workfile_has_unsaved_changes(self) -> bool: + """Override wokfile_has_unsaved_changes method from IWorkfileHost. + Returns True if opened workfile has no unsaved changes. + + Returns: + bool: True if scene is saved and False if it has unsaved + modifications. + """ + return has_unsaved_changes() + + def work_root(self, session) -> str: + """Override work_root method from IWorkfileHost. + Modify workdir per host. + + Args: + session (dict): Session context data. + + Returns: + str: Path to new workdir. + """ + return work_root(session) + def get_context_data(self) -> dict: """Override abstract method from IPublishHost. Get global data related to creation-publishing from workfile. From e9d022bab37f67391267788c3de940c1db0f955d Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 2 Aug 2023 16:13:34 +0200 Subject: [PATCH 0794/1224] Handling optional plugins --- openpype/hosts/blender/plugins/publish/extract_abc.py | 2 +- .../blender/plugins/publish/extract_abc_animation.py | 5 ++++- .../blender/plugins/publish/extract_blend_animation.py | 5 ++++- .../blender/plugins/publish/extract_camera_abc.py | 2 +- .../blender/plugins/publish/extract_camera_fbx.py | 2 +- openpype/hosts/blender/plugins/publish/extract_fbx.py | 2 +- .../blender/plugins/publish/extract_fbx_animation.py | 5 ++++- .../hosts/blender/plugins/publish/extract_layout.py | 2 +- .../hosts/blender/plugins/publish/extract_playblast.py | 2 +- .../plugins/publish/increment_workfile_version.py | 6 +++++- .../blender/plugins/publish/integrate_animation.py | 6 +++++- .../blender/plugins/publish/validate_mesh_has_uv.py | 10 ++++++++-- .../blender/plugins/publish/validate_object_mode.py | 6 +++++- 13 files changed, 41 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 7b6c4d7ae7..5af8104344 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractABC(publish.Extractor): +class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as ABC.""" label = "Extract ABC" diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index 44b2ba3761..0b6b93b7a5 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -6,7 +6,10 @@ from openpype.pipeline import publish from openpype.hosts.blender.api import plugin -class ExtractAnimationABC(publish.Extractor): +class ExtractAnimationABC( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract as ABC.""" label = "Extract Animation ABC" diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 477411b73d..3a5b788c9e 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -5,7 +5,10 @@ import bpy from openpype.pipeline import publish -class ExtractBlendAnimation(publish.Extractor): +class ExtractBlendAnimation( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract a blend file.""" label = "Extract Blend" diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index 036be7bf3c..2a327f4d65 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractCameraABC(publish.Extractor): +class ExtractCameraABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract camera as ABC.""" label = "Extract Camera (ABC)" diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py index 315994140e..8e5e4d37d4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py @@ -6,7 +6,7 @@ from openpype.pipeline import publish from openpype.hosts.blender.api import plugin -class ExtractCamera(publish.Extractor): +class ExtractCamera(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as the camera as FBX.""" label = "Extract Camera (FBX)" diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index 0ad797c226..8ace6a43a7 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractFBX(publish.Extractor): +class ExtractFBX(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as FBX.""" label = "Extract FBX" diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 062b42e99d..04f50f8207 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -10,7 +10,10 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractAnimationFBX(publish.Extractor): +class ExtractAnimationFBX( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract as animation.""" label = "Extract FBX" diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index f2d04f1178..8445560bba 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -11,7 +11,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractLayout(publish.Extractor): +class ExtractLayout(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a layout.""" label = "Extract Layout" diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py index 196e75b8cc..82ddbc1fc2 100644 --- a/openpype/hosts/blender/plugins/publish/extract_playblast.py +++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py @@ -9,7 +9,7 @@ from openpype.hosts.blender.api import capture from openpype.hosts.blender.api.lib import maintained_time -class ExtractPlayblast(publish.Extractor): +class ExtractPlayblast(publish.Extractor, publish.OptionalPyblishPluginMixin): """ Extract viewport playblast. diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 6ace14d77c..176668f366 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -1,8 +1,12 @@ import pyblish.api +from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.blender.api.workio import save_file -class IncrementWorkfileVersion(pyblish.api.ContextPlugin): +class IncrementWorkfileVersion( + pyblish.api.ContextPlugin, + OptionalPyblishPluginMixin +): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 0.9 diff --git a/openpype/hosts/blender/plugins/publish/integrate_animation.py b/openpype/hosts/blender/plugins/publish/integrate_animation.py index d9a85bc79b..b7e5423fa8 100644 --- a/openpype/hosts/blender/plugins/publish/integrate_animation.py +++ b/openpype/hosts/blender/plugins/publish/integrate_animation.py @@ -1,9 +1,13 @@ import json import pyblish.api +from openpype.pipeline.publish import OptionalPyblishPluginMixin -class IntegrateAnimation(pyblish.api.InstancePlugin): +class IntegrateAnimation( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Generate a JSON file for animation.""" label = "Integrate Animation" diff --git a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py index edf47193be..687371b362 100644 --- a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py +++ b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py @@ -4,11 +4,17 @@ import bpy import pyblish.api -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, +) import openpype.hosts.blender.api.action -class ValidateMeshHasUvs(pyblish.api.InstancePlugin): +class ValidateMeshHasUvs( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Validate that the current mesh has UV's.""" order = ValidateContentsOrder diff --git a/openpype/hosts/blender/plugins/publish/validate_object_mode.py b/openpype/hosts/blender/plugins/publish/validate_object_mode.py index ac60e00f89..d8d2e3c8bf 100644 --- a/openpype/hosts/blender/plugins/publish/validate_object_mode.py +++ b/openpype/hosts/blender/plugins/publish/validate_object_mode.py @@ -3,10 +3,14 @@ from typing import List import bpy import pyblish.api +from openpype.pipeline.publish import OptionalPyblishPluginMixin import openpype.hosts.blender.api.action -class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin): +class ValidateObjectIsInObjectMode( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Validate that the objects in the instance are in Object Mode.""" order = pyblish.api.ValidatorOrder - 0.01 From 656a42dd525317f83b22a232f8dc68756d2c389a Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 9 Aug 2023 11:45:58 +0200 Subject: [PATCH 0795/1224] Handling instance noce --- openpype/hosts/blender/api/plugin.py | 13 ++++++++++--- .../hosts/blender/plugins/create/create_action.py | 8 ++++++++ .../blender/plugins/create/create_animation.py | 6 ++++++ .../hosts/blender/plugins/create/create_camera.py | 6 ++++++ .../hosts/blender/plugins/create/create_layout.py | 6 ++++++ .../hosts/blender/plugins/create/create_model.py | 6 ++++++ .../blender/plugins/create/create_pointcache.py | 6 ++++++ .../hosts/blender/plugins/create/create_review.py | 6 ++++++ openpype/hosts/blender/plugins/create/create_rig.py | 6 ++++++ 9 files changed, 60 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 5dd352ff6c..fe0d53f84e 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -234,13 +234,20 @@ class BlenderCreator(Creator): pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ + collection = bpy.data.collections.new(name=subset_name) + bpy.context.scene.collection.children.link(collection) + + instance_node = {} + for key, value in collection.items(): + instance_node[key] = value + + instance_data["instance_node"] = instance_node + instance = CreatedInstance( self.family, subset_name, instance_data, self ) self._add_instance_to_context(instance) - collection = bpy.data.collections.new(name=subset_name) - bpy.context.scene.collection.children.link(collection) imprint(collection, instance_data) if pre_create_data.get("useSelection"): @@ -287,7 +294,7 @@ class BlenderCreator(Creator): for created_instance, _changes in update_list: data = created_instance.data_to_store() - # TODO + imprint(data.get("instance_node", {}), data) def remove_instances(self, instances: List[CreatedInstance]): diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 6951e86c46..a43258082c 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -28,6 +28,11 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): ) collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) + + instance_node = {} + for key, value in collection.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -35,8 +40,11 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) + from pprint import pprint + pprint(instance_data) lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 9e7dfbaf84..842292f0f9 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -46,6 +46,11 @@ class CreateAnimation(plugin.BlenderCreator): # asset_group.empty_display_type = 'SINGLE_ARROW' asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -53,6 +58,7 @@ class CreateAnimation(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index c5987779c0..8360abbc7d 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -44,6 +44,11 @@ class CreateCamera(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -51,6 +56,7 @@ class CreateCamera(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index cb61799f4a..b4b127f32c 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -43,6 +43,11 @@ class CreateLayout(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -50,6 +55,7 @@ class CreateLayout(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index ccf3668d98..5cb2de0fae 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -43,6 +43,11 @@ class CreateModel(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -50,6 +55,7 @@ class CreateModel(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index e27cb22389..a95ae547c8 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -28,6 +28,11 @@ class CreatePointcache(plugin.BlenderCreator): ) collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) + + instance_node = {} + for key, value in collection.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -35,6 +40,7 @@ class CreatePointcache(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(collection, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index afeaea951b..1bcafbc265 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -42,6 +42,11 @@ class CreateReview(plugin.BlenderCreator): name = plugin.asset_name(instance_data["asset"], subset_name) asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -49,6 +54,7 @@ class CreateReview(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 2db766f7ed..e93f4a171f 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -43,6 +43,11 @@ class CreateRig(plugin.BlenderCreator): asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) + + instance_node = {} + for key, value in asset_group.items(): + instance_node[key] = value + instance_data.update( { "id": "pyblish.avalon.instance", @@ -50,6 +55,7 @@ class CreateRig(plugin.BlenderCreator): "label": self.label, "task": get_current_task_name(), "subset": subset_name, + "instance_node": instance_node, } ) lib.imprint(asset_group, instance_data) From 5f7847c6973d378779e3b27a0ef25ed3fa5a9e1c Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 9 Aug 2023 12:04:06 +0200 Subject: [PATCH 0796/1224] Removed useless pprint --- openpype/hosts/blender/plugins/create/create_action.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index a43258082c..ee7ca092b5 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -43,8 +43,6 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): "instance_node": instance_node, } ) - from pprint import pprint - pprint(instance_data) lib.imprint(collection, instance_data) if pre_create_data.get("useSelection"): From f52c9f0a38de8c9d3f50d6a1a721f20afdfa1b5f Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 10 Aug 2023 10:37:46 +0200 Subject: [PATCH 0797/1224] Enhance instance_node, wip remove_inst, rm extra empty lines --- openpype/hosts/blender/api/plugin.py | 22 ++++++++++++++----- .../blender/plugins/create/create_action.py | 8 ++++--- .../plugins/create/create_animation.py | 12 ++++++---- .../blender/plugins/create/create_camera.py | 12 ++++++---- .../blender/plugins/create/create_layout.py | 12 ++++++---- .../blender/plugins/create/create_model.py | 12 ++++++---- .../plugins/create/create_pointcache.py | 8 ++++--- .../blender/plugins/create/create_review.py | 12 ++++++---- .../blender/plugins/create/create_rig.py | 12 ++++++---- 9 files changed, 75 insertions(+), 35 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index fe0d53f84e..9967f9479c 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -155,7 +155,6 @@ class BlenderCreator(Creator): return collection - @staticmethod def cache_subsets(shared_data): """Cache instances for Creators shared data. @@ -221,7 +220,6 @@ class BlenderCreator(Creator): return shared_data - def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): @@ -254,7 +252,6 @@ class BlenderCreator(Creator): for obj in get_selection(): collection.objects.link(obj) - def collect_instances(self): """Override abstract method from BaseCreator. Collect existing instances related to this creator plugin.""" @@ -283,7 +280,6 @@ class BlenderCreator(Creator): # Add instance to create context self._add_instance_to_context(instance) - def update_instances(self, update_list): """Override abstract method from BaseCreator. Store changes of existing instances so they can be recollected. @@ -296,7 +292,6 @@ class BlenderCreator(Creator): imprint(data.get("instance_node", {}), data) - def remove_instances(self, instances: List[CreatedInstance]): """Override abstract method from BaseCreator. Method called when instances are removed. @@ -305,6 +300,23 @@ class BlenderCreator(Creator): instance(List[CreatedInstance]): Instance objects to remove. """ for instance in instances: + outliner_entity = instance.data.get("instance_node", {}).get( + "datablock" + ) + if not outliner_entity: + continue + + if isinstance(outliner_entity, bpy.types.Collection): + for children in outliner_entity.children_recursive: + if isinstance(children, bpy.types.Collection): + bpy.data.collections.remove(children) + else: + bpy.data.objects.remove(children) + + bpy.data.collections.remove(outliner_entity) + elif isinstance(outliner_entity, bpy.types.Object): + bpy.data.objects.remove(outliner_entity) + self._remove_instance_from_context(instance) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index ee7ca092b5..7404e7e037 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -5,6 +5,7 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance import openpype.hosts.blender.api.plugin from openpype.hosts.blender.api import lib +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): @@ -29,9 +30,10 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) - instance_node = {} - for key, value in collection.items(): - instance_node[key] = value + collection[AVALON_PROPERTY] = instance_node = { + "name": collection.name, + "datablock": collection, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 842292f0f9..c2f24250e1 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateAnimation(plugin.BlenderCreator): @@ -47,9 +50,10 @@ class CreateAnimation(plugin.BlenderCreator): asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 8360abbc7d..a83124cbe7 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateCamera(plugin.BlenderCreator): @@ -45,9 +48,10 @@ class CreateCamera(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index b4b127f32c..4fb76ef41e 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateLayout(plugin.BlenderCreator): @@ -44,9 +47,10 @@ class CreateLayout(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 5cb2de0fae..45f1d66ad9 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateModel(plugin.BlenderCreator): @@ -44,9 +47,10 @@ class CreateModel(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index a95ae547c8..7aa3b22466 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -5,6 +5,7 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class CreatePointcache(plugin.BlenderCreator): @@ -29,9 +30,10 @@ class CreatePointcache(plugin.BlenderCreator): collection = bpy.data.collections.new(name=name) bpy.context.scene.collection.children.link(collection) - instance_node = {} - for key, value in collection.items(): - instance_node[key] = value + collection[AVALON_PROPERTY] = instance_node = { + "name": collection.name, + "datablock": collection, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 1bcafbc265..a6ca5b1b92 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateReview(plugin.BlenderCreator): @@ -43,9 +46,10 @@ class CreateReview(plugin.BlenderCreator): asset_group = bpy.data.collections.new(name=name) instances.children.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index e93f4a171f..f7c99b0b03 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -4,7 +4,10 @@ import bpy from openpype.pipeline import get_current_task_name, CreatedInstance from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateRig(plugin.BlenderCreator): @@ -44,9 +47,10 @@ class CreateRig(plugin.BlenderCreator): asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - instance_node = {} - for key, value in asset_group.items(): - instance_node[key] = value + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name, + "datablock": asset_group, + } instance_data.update( { From 371d7405ec10257fe52c1259c383604862b080f7 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 10 Aug 2023 11:19:05 +0200 Subject: [PATCH 0798/1224] Apply creator change to bl base creator --- openpype/hosts/blender/api/plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 9967f9479c..060d229eb5 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -235,9 +235,10 @@ class BlenderCreator(Creator): collection = bpy.data.collections.new(name=subset_name) bpy.context.scene.collection.children.link(collection) - instance_node = {} - for key, value in collection.items(): - instance_node[key] = value + collection["instance_node"] = instance_node = { + "name": collection.name, + "datablock": collection, + } instance_data["instance_node"] = instance_node From e832cf59ccb2bbf6a436184f1143ebf53231c5df Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 12 Sep 2023 15:49:33 +0200 Subject: [PATCH 0799/1224] Fix Cannot pickle object error --- openpype/hosts/blender/api/plugin.py | 5 +---- openpype/hosts/blender/plugins/create/create_action.py | 1 - openpype/hosts/blender/plugins/create/create_animation.py | 1 - openpype/hosts/blender/plugins/create/create_camera.py | 1 - openpype/hosts/blender/plugins/create/create_layout.py | 1 - openpype/hosts/blender/plugins/create/create_model.py | 1 - openpype/hosts/blender/plugins/create/create_pointcache.py | 1 - openpype/hosts/blender/plugins/create/create_review.py | 1 - openpype/hosts/blender/plugins/create/create_rig.py | 1 - 9 files changed, 1 insertion(+), 12 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 060d229eb5..73d8fc0ed5 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -237,7 +237,6 @@ class BlenderCreator(Creator): collection["instance_node"] = instance_node = { "name": collection.name, - "datablock": collection, } instance_data["instance_node"] = instance_node @@ -267,9 +266,7 @@ class BlenderCreator(Creator): for instance_data in cached_subsets.get(self.identifier, []): # Process only instances that were created by this creator - data = {} - for key, value in instance_data.items(): - data[key] = value + data = instance_data.to_dict() creator_id = data.get('creator_identifier') if creator_id == self.identifier: diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 7404e7e037..d766fce038 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -32,7 +32,6 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): collection[AVALON_PROPERTY] = instance_node = { "name": collection.name, - "datablock": collection, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index c2f24250e1..88ae9e5996 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -52,7 +52,6 @@ class CreateAnimation(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index a83124cbe7..026b5739d6 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -50,7 +50,6 @@ class CreateCamera(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 4fb76ef41e..f46ae58a43 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -49,7 +49,6 @@ class CreateLayout(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 45f1d66ad9..069b78626b 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -49,7 +49,6 @@ class CreateModel(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 7aa3b22466..3054b81ef5 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -32,7 +32,6 @@ class CreatePointcache(plugin.BlenderCreator): collection[AVALON_PROPERTY] = instance_node = { "name": collection.name, - "datablock": collection, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index a6ca5b1b92..10a96c94fd 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -48,7 +48,6 @@ class CreateReview(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index f7c99b0b03..8daffe638d 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -49,7 +49,6 @@ class CreateRig(plugin.BlenderCreator): asset_group[AVALON_PROPERTY] = instance_node = { "name": asset_group.name, - "datablock": asset_group, } instance_data.update( From 78b33370dc3418083d323d465332ff28aa32fbeb Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Mon, 18 Sep 2023 17:59:33 +0200 Subject: [PATCH 0800/1224] Fix collectreview --- openpype/hosts/blender/plugins/publish/collect_review.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 3bf2e39e24..66a3d7b5e8 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -16,10 +16,14 @@ class CollectReview(pyblish.api.InstancePlugin): self.log.debug(f"instance: {instance}") + datablock = bpy.data.collections.get( + instance.data.get("instance_node", {}).get("name", "") + ) + # get cameras cameras = [ obj - for obj in instance + for obj in datablock.all_objects if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA" ] From d11b00510388a797f5e0229d13a2f3bf6b837460 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 24 Oct 2023 21:08:54 +0800 Subject: [PATCH 0801/1224] make sure the path is normalized during the update for the loaders --- openpype/hosts/max/plugins/load/load_tycache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index 41ea267c3d..1373404274 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -42,7 +42,7 @@ class TyCacheLoader(load.LoaderPlugin): """update the container""" from pymxs import runtime as rt - path = get_representation_path(representation) + path = os.path.normpath(get_representation_path(representation)) node = rt.GetNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) update_custom_attribute_data(node, node_list) From a526260b461a85e951a1fc87697a56df70afa7d3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 17:57:09 +0200 Subject: [PATCH 0802/1224] 'get_assets' function can find folders by path and by name --- openpype/client/server/entities.py | 57 ++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 16223d3d91..c1e27eabb9 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -183,6 +183,20 @@ def get_asset_by_name(project_name, asset_name, fields=None): return None + +def _folders_query(project_name, con, fields, **kwargs): + if fields is None or "tasks" in fields: + folders = get_folders_with_tasks( + con, project_name, fields=fields, **kwargs + ) + + else: + folders = con.get_folders(project_name, fields=fields, **kwargs) + + for folder in folders: + yield folder + + def get_assets( project_name, asset_ids=None, @@ -199,22 +213,51 @@ def get_assets( active = None con = get_server_api_connection() + fields = folder_fields_v3_to_v4(fields, con) kwargs = dict( folder_ids=asset_ids, - folder_names=asset_names, parent_ids=parent_ids, active=active, - fields=fields ) + if not asset_names: + for folder in _folders_query(project_name, con, fields, **kwargs): + yield convert_v4_folder_to_v3(folder, project_name) + return - if fields is None or "tasks" in fields: - folders = get_folders_with_tasks(con, project_name, **kwargs) + new_asset_names = set() + folder_paths = set() + if asset_names: + for name in asset_names: + if "/" in name: + folder_paths.add(name) + else: + new_asset_names.add(name) - else: - folders = con.get_folders(project_name, **kwargs) + if folder_paths: + for folder in _folders_query( + project_name, con, fields, folder_paths=folder_paths, **kwargs + ): + yield convert_v4_folder_to_v3(folder, project_name) - for folder in folders: + if not new_asset_names: + return + + folders_by_name = collections.defaultdict(list) + for folder in _folders_query( + project_name, con, fields, folder_names=new_asset_names, **kwargs + ): + folders_by_name[folder["name"]].append(folder) + + for name, folders in folders_by_name.items(): + folder = next( + ( + folder + for folder in folders + if folder["path"] == name + ), + folders[0] + ) yield convert_v4_folder_to_v3(folder, project_name) From 331c4833ade7acf5ab0db7952e9821b817354d2d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 17:58:34 +0200 Subject: [PATCH 0803/1224] added helper function 'get_asset_name_identifier' to receive folder path in AYON mode and asset name in openpype --- openpype/client/__init__.py | 4 ++++ openpype/client/entities.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/openpype/client/__init__.py b/openpype/client/__init__.py index 7831afd8ad..a313d6b3cc 100644 --- a/openpype/client/__init__.py +++ b/openpype/client/__init__.py @@ -43,6 +43,8 @@ from .entities import ( get_thumbnail_id_from_source, get_workfile_info, + + get_asset_name_identifier, ) from .entity_links import ( @@ -105,4 +107,6 @@ __all__ = ( "get_linked_representation_id", "create_project", + + "get_asset_name_identifier", ) diff --git a/openpype/client/entities.py b/openpype/client/entities.py index 5d9654c611..d085f90028 100644 --- a/openpype/client/entities.py +++ b/openpype/client/entities.py @@ -4,3 +4,22 @@ if not AYON_SERVER_ENABLED: from .mongo.entities import * else: from .server.entities import * + + +def get_asset_name_identifier(asset_doc): + """Get asset name identifier by asset document. + + This function is added because of AYON implementation where name + identifier is not just a name but full path. + + Asset document must have "name" key, and "data.parents" when in AYON mode. + + Args: + asset_doc (dict[str, Any]): Asset document. + """ + + if not AYON_SERVER_ENABLED: + return asset_doc["name"] + parents = list(asset_doc["data"]["parents"]) + parents.append(asset_doc["name"]) + return "/".join(parents) From 4c6ec4b9bcb245892421b14bee26d7564f33f971 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:00:58 +0200 Subject: [PATCH 0804/1224] instances in AYON mode have 'folderPath' instead of 'asset' --- openpype/pipeline/create/context.py | 37 ++++++++++++++++--- .../publish/collect_from_create_context.py | 3 ++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 25f03ddd3b..333ab25f54 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -11,7 +11,12 @@ from contextlib import contextmanager import pyblish.logic import pyblish.api -from openpype.client import get_assets, get_asset_by_name +from openpype import AYON_SERVER_ENABLED +from openpype.client import ( + get_assets, + get_asset_by_name, + get_asset_name_identifier, +) from openpype.settings import ( get_system_settings, get_project_settings @@ -922,9 +927,19 @@ class CreatedInstance: self._orig_data = copy.deepcopy(data) # Pop family and subset to prevent unexpected changes + # TODO change to 'productType' and 'productName' in AYON data.pop("family", None) data.pop("subset", None) + if AYON_SERVER_ENABLED: + asset_name = data.pop("asset", None) + if "folderPath" not in data: + data["folderPath"] = asset_name + + elif "folderPath" in data: + asset_name = data.pop("folderPath").split("/")[-1] + if "asset" not in data: + data["asset"] = asset_name # QUESTION Does it make sense to have data stored as ordered dict? self._data = collections.OrderedDict() @@ -1268,6 +1283,8 @@ class CreatedInstance: def has_set_asset(self): """Asset name is set in data.""" + if AYON_SERVER_ENABLED: + return "folderPath" in self._data return "asset" in self._data @property @@ -2229,7 +2246,10 @@ class CreateContext: task_names_by_asset_name = {} for instance in instances: task_name = instance.get("task") - asset_name = instance.get("asset") + if AYON_SERVER_ENABLED: + asset_name = instance.get("folderPath") + else: + asset_name = instance.get("asset") if asset_name: task_names_by_asset_name[asset_name] = set() if task_name: @@ -2240,15 +2260,18 @@ class CreateContext: for asset_name in task_names_by_asset_name.keys() if asset_name is not None ] + fields = {"name", "data.tasks"} + if AYON_SERVER_ENABLED: + fields |= {"data.parents"} asset_docs = list(get_assets( self.project_name, asset_names=asset_names, - fields=["name", "data.tasks"] + fields=fields )) task_names_by_asset_name = {} for asset_doc in asset_docs: - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) tasks = asset_doc.get("data", {}).get("tasks") or {} task_names_by_asset_name[asset_name] = set(tasks.keys()) @@ -2256,7 +2279,11 @@ class CreateContext: if not instance.has_valid_asset or not instance.has_valid_task: continue - asset_name = instance["asset"] + if AYON_SERVER_ENABLED: + asset_name = instance["folderPath"] + else: + asset_name = instance["asset"] + if asset_name not in task_names_by_asset_name: instance.set_asset_invalid(True) continue diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 8806a13ca0..84f6141069 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -4,6 +4,7 @@ import os import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.host import IPublishHost from openpype.pipeline import legacy_io, registered_host from openpype.pipeline.create import CreateContext @@ -38,6 +39,8 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): for created_instance in create_context.instances: instance_data = created_instance.data_to_store() + if AYON_SERVER_ENABLED: + instance_data["asset"] = instance_data.pop("folderPath") if instance_data["active"]: thumbnail_path = thumbnail_paths_by_instance_id.get( created_instance.id From 0ddd95eacf0fda0b058fccc3388d0816aa9d4145 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:02:41 +0200 Subject: [PATCH 0805/1224] use 'get_asset_name_identifier' in pipeline logic --- openpype/pipeline/context_tools.py | 5 ++--- openpype/pipeline/create/utils.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 13630ae7ca..e20099759a 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -17,6 +17,7 @@ from openpype.client import ( get_asset_by_id, get_asset_by_name, version_is_latest, + get_asset_name_identifier, ) from openpype.lib.events import emit_event from openpype.modules import load_modules, ModulesManager @@ -568,14 +569,12 @@ def compute_session_changes( Dict[str, str]: Changes in the Session dictionary. """ - changes = {} - # Get asset document and asset if not asset_doc: task_name = None asset_name = None else: - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) # Detect any changes compared session mapping = { diff --git a/openpype/pipeline/create/utils.py b/openpype/pipeline/create/utils.py index 2ef1f02bd6..ce4af8f474 100644 --- a/openpype/pipeline/create/utils.py +++ b/openpype/pipeline/create/utils.py @@ -1,6 +1,11 @@ import collections -from openpype.client import get_assets, get_subsets, get_last_versions +from openpype.client import ( + get_assets, + get_subsets, + get_last_versions, + get_asset_name_identifier, +) def get_last_versions_for_instances( @@ -52,10 +57,10 @@ def get_last_versions_for_instances( asset_docs = get_assets( project_name, asset_names=subset_names_by_asset_name.keys(), - fields=["name", "_id"] + fields=["name", "_id", "data.parents"] ) asset_names_by_id = { - asset_doc["_id"]: asset_doc["name"] + asset_doc["_id"]: get_asset_name_identifier(asset_doc) for asset_doc in asset_docs } if not asset_names_by_id: From 922c481d95b6e377a8d7433819360c45179fcffa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:03:02 +0200 Subject: [PATCH 0806/1224] use 'get_asset_name_identifier' for 'AVALON_ASSET' on app start --- openpype/lib/applications.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index ff5e27c122..4d75a01e1d 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -12,6 +12,7 @@ from abc import ABCMeta, abstractmethod import six from openpype import AYON_SERVER_ENABLED, PACKAGE_DIR +from openpype.client import get_asset_name_identifier from openpype.settings import ( get_system_settings, get_project_settings, @@ -1728,7 +1729,9 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): "AVALON_APP_NAME": app.full_name } if asset_doc: - context_env["AVALON_ASSET"] = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) + context_env["AVALON_ASSET"] = asset_name + if task_name: context_env["AVALON_TASK"] = task_name From f38c3f395e68df0a33a3b1ba135dbbcf0cf7f899 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:03:20 +0200 Subject: [PATCH 0807/1224] use 'get_asset_name_identifier' in global plugins --- .../publish/collect_anatomy_instance_data.py | 20 +++++++++++-------- openpype/plugins/publish/collect_audio.py | 10 ++++++---- .../publish/extract_hierarchy_to_ayon.py | 4 ++-- .../publish/validate_editorial_asset_name.py | 9 ++++++--- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index b4f4d6a16a..cc6da9b2c3 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -30,7 +30,8 @@ import pyblish.api from openpype.client import ( get_assets, get_subsets, - get_last_versions + get_last_versions, + get_asset_name_identifier, ) from openpype.pipeline.version_start import get_versioning_start @@ -60,6 +61,9 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Querying asset documents for instances.") context_asset_doc = context.data.get("assetEntity") + context_asset_name = None + if context_asset_doc: + context_asset_name = get_asset_name_identifier(context_asset_doc) instances_with_missing_asset_doc = collections.defaultdict(list) for instance in context: @@ -68,15 +72,15 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # There is possibility that assetEntity on instance is already set # which can happen in standalone publisher - if ( - instance_asset_doc - and instance_asset_doc["name"] == _asset_name - ): - continue + if instance_asset_doc: + instance_asset_name = get_asset_name_identifier( + instance_asset_doc) + if instance_asset_name == _asset_name: + continue # Check if asset name is the same as what is in context # - they may be different, e.g. in NukeStudio - if context_asset_doc and context_asset_doc["name"] == _asset_name: + if context_asset_name and context_asset_name == _asset_name: instance.data["assetEntity"] = context_asset_doc else: @@ -93,7 +97,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): asset_docs = get_assets(project_name, asset_names=asset_names) asset_docs_by_name = { - asset_doc["name"]: asset_doc + get_asset_name_identifier(asset_doc): asset_doc for asset_doc in asset_docs } diff --git a/openpype/plugins/publish/collect_audio.py b/openpype/plugins/publish/collect_audio.py index 6aaadfc568..734a625852 100644 --- a/openpype/plugins/publish/collect_audio.py +++ b/openpype/plugins/publish/collect_audio.py @@ -6,6 +6,7 @@ from openpype.client import ( get_subsets, get_last_versions, get_representations, + get_asset_name_identifier, ) from openpype.pipeline.load import get_representation_path_with_anatomy @@ -121,12 +122,13 @@ class CollectAudio(pyblish.api.ContextPlugin): asset_docs = get_assets( project_name, asset_names=asset_names, - fields=["_id", "name"] + fields=["_id", "name", "data.parents"] ) - asset_id_by_name = {} - for asset_doc in asset_docs: - asset_id_by_name[asset_doc["name"]] = asset_doc["_id"] + asset_id_by_name = { + get_asset_name_identifier(asset_doc): asset_doc["_id"] + for asset_doc in asset_docs + } asset_ids = set(asset_id_by_name.values()) # Query subsets with name define by 'audio_subset_name' attr diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 0d9131718b..fe8cb40ad2 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -8,7 +8,7 @@ from ayon_api import slugify_string from ayon_api.entity_hub import EntityHub from openpype import AYON_SERVER_ENABLED -from openpype.client import get_assets +from openpype.client import get_assets, get_asset_name_identifier from openpype.pipeline.template_data import ( get_asset_template_data, get_task_template_data, @@ -58,7 +58,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): project_name, asset_names=instances_by_asset_name.keys() ) asset_docs_by_name = { - asset_doc["name"]: asset_doc + get_asset_name_identifier(asset_doc): asset_doc for asset_doc in asset_docs } for asset_name, instances in instances_by_asset_name.items(): diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index fca0d8e7f5..b5afc49f2e 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -2,7 +2,7 @@ from pprint import pformat import pyblish.api -from openpype.client import get_assets +from openpype.client import get_assets, get_asset_name_identifier class ValidateEditorialAssetName(pyblish.api.ContextPlugin): @@ -34,8 +34,11 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): self.log.debug("__ db_assets: {}".format(db_assets)) asset_db_docs = { - str(e["name"]): [str(p) for p in e["data"]["parents"]] - for e in db_assets} + get_asset_name_identifier(asset_doc): list( + asset_doc["data"]["parents"] + ) + for asset_doc in db_assets + } self.log.debug("__ project_entities: {}".format( pformat(asset_db_docs))) From c505513b056e092ac8dd8c3652776a238046980c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:04:31 +0200 Subject: [PATCH 0808/1224] use folder path in ayon tools to define current context --- openpype/tools/ayon_launcher/models/actions.py | 6 +++--- openpype/tools/ayon_loader/control.py | 2 +- openpype/tools/ayon_sceneinventory/control.py | 11 ++--------- openpype/tools/ayon_workfiles/control.py | 2 +- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py index 93ec115734..d7c4219dc2 100644 --- a/openpype/tools/ayon_launcher/models/actions.py +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -402,12 +402,12 @@ class ActionsModel: ) def _prepare_session(self, project_name, folder_id, task_id): - folder_name = None + folder_path = None if folder_id: folder = self._controller.get_folder_entity( project_name, folder_id) if folder: - folder_name = folder["name"] + folder_path = folder["path"] task_name = None if task_id: @@ -417,7 +417,7 @@ class ActionsModel: return { "AVALON_PROJECT": project_name, - "AVALON_ASSET": folder_name, + "AVALON_ASSET": folder_path, "AVALON_TASK": task_name, } diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 2b779f5c2e..d2fae35f32 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -289,7 +289,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): 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( + folder = ayon_api.get_folder_by_path( project_name, asset_name, fields=["id"] ) if folder: diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py index e98b0e307b..6111d7e43b 100644 --- a/openpype/tools/ayon_sceneinventory/control.py +++ b/openpype/tools/ayon_sceneinventory/control.py @@ -70,19 +70,12 @@ class SceneInventoryController: 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_name: + folder = ayon_api.get_folder_by_path(project_name, folder_name) 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 diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index 3784959caf..d86b04badb 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -427,7 +427,7 @@ class BaseWorkfileController( task_name = context["task_name"] folder_id = None if folder_name: - folder = ayon_api.get_folder_by_name(project_name, folder_name) + folder = ayon_api.get_folder_by_path(project_name, folder_name) if folder: folder_id = folder["id"] From 1ac66764d49af599915a592de0917a4b67977358 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:05:16 +0200 Subject: [PATCH 0809/1224] use folder path in publisher --- openpype/tools/publisher/control.py | 6 ++- .../tools/publisher/widgets/assets_widget.py | 42 ++++++++++++++++--- openpype/tools/publisher/widgets/widgets.py | 17 ++++++-- openpype/tools/utils/assets_widget.py | 1 + 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index a6264303d5..ad87bdf607 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -12,10 +12,12 @@ from abc import ABCMeta, abstractmethod import six import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_assets, get_asset_by_id, get_subsets, + get_asset_name_identifier, ) from openpype.lib.events import EventSystem from openpype.lib.attribute_definitions import ( @@ -73,6 +75,8 @@ class AssetDocsCache: "data.visualParent": True, "data.tasks": True } + if AYON_SERVER_ENABLED: + projection["data.parents"] = True def __init__(self, controller): self._controller = controller @@ -105,7 +109,7 @@ class AssetDocsCache: elif "tasks" not in asset_doc["data"]: asset_doc["data"]["tasks"] = {} - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) asset_tasks = asset_doc["data"]["tasks"] task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) asset_docs_by_name[asset_name] = asset_doc diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index c536f93c9b..5f74b79c99 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -11,7 +11,8 @@ from openpype.tools.utils import ( from openpype.tools.utils.assets_widget import ( SingleSelectAssetsWidget, ASSET_ID_ROLE, - ASSET_NAME_ROLE + ASSET_NAME_ROLE, + ASSET_PATH_ROLE, ) @@ -31,6 +32,15 @@ class CreateWidgetAssetsWidget(SingleSelectAssetsWidget): self._last_filter_height = None + def get_selected_asset_name(self): + if AYON_SERVER_ENABLED: + selection_model = self._view.selectionModel() + indexes = selection_model.selectedRows() + for index in indexes: + return index.data(ASSET_PATH_ROLE) + return None + return super(CreateWidgetAssetsWidget, self).get_selected_asset_name() + def _check_header_height(self): """Catch header height changes. @@ -100,21 +110,24 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): self._controller = controller self._items_by_name = {} + self._items_by_path = {} self._items_by_asset_id = {} def reset(self): self.clear() self._items_by_name = {} + self._items_by_path = {} self._items_by_asset_id = {} assets_by_parent_id = self._controller.get_asset_hierarchy() items_by_name = {} + items_by_path = {} items_by_asset_id = {} _queue = collections.deque() - _queue.append((self.invisibleRootItem(), None)) + _queue.append((self.invisibleRootItem(), None, None)) while _queue: - parent_item, parent_id = _queue.popleft() + parent_item, parent_id, parent_path = _queue.popleft() children = assets_by_parent_id.get(parent_id) if not children: continue @@ -127,6 +140,9 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): for name in sorted(children_by_name.keys()): child = children_by_name[name] child_id = child["_id"] + child_path = name + if parent_path: + child_path = "{}/{}".format(parent_path, child_path) has_children = bool(assets_by_parent_id.get(child_id)) icon = get_asset_icon(child, has_children) @@ -138,15 +154,18 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): item.setData(icon, QtCore.Qt.DecorationRole) item.setData(child_id, ASSET_ID_ROLE) item.setData(name, ASSET_NAME_ROLE) + item.setData(child_path, ASSET_PATH_ROLE) items_by_name[name] = item + items_by_path[child_path] = item items_by_asset_id[child_id] = item items.append(item) - _queue.append((item, child_id)) + _queue.append((item, child_id, child_path)) parent_item.appendRows(items) self._items_by_name = items_by_name + self._items_by_path = items_by_path self._items_by_asset_id = items_by_asset_id def get_index_by_asset_id(self, asset_id): @@ -156,12 +175,20 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() def get_index_by_asset_name(self, asset_name): - item = self._items_by_name.get(asset_name) + item = None + if AYON_SERVER_ENABLED: + item = self._items_by_path.get(asset_name) + + if item is None: + item = self._items_by_name.get(asset_name) + if item is None: return QtCore.QModelIndex() return item.index() def name_is_valid(self, item_name): + if AYON_SERVER_ENABLED and item_name in self._items_by_path: + return True return item_name in self._items_by_name @@ -296,7 +323,10 @@ class AssetsDialog(QtWidgets.QDialog): index = self._asset_view.currentIndex() asset_name = None if index.isValid(): - asset_name = index.data(ASSET_NAME_ROLE) + if AYON_SERVER_ENABLED: + asset_name = index.data(ASSET_PATH_ROLE) + else: + asset_name = index.data(ASSET_NAME_ROLE) self._selected_asset = asset_name self.done(1) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1bbe73381f..cc0f7a9f97 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -538,6 +538,7 @@ class AssetsField(BaseClickableFrame): Does not change selected items (assets). """ self._name_input.setText(text) + self._name_input.end(False) def set_selected_items(self, asset_names=None): """Set asset names for selection of instances. @@ -1162,7 +1163,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget): invalid_tasks = False for instance in self._current_instances: new_variant_value = instance.get("variant") - new_asset_name = instance.get("asset") + if AYON_SERVER_ENABLED: + new_asset_name = instance.get("folderPath") + else: + new_asset_name = instance.get("asset") new_task_name = instance.get("task") if variant_value is not None: new_variant_value = variant_value @@ -1193,7 +1197,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget): instance["variant"] = variant_value if asset_name is not None: - instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + instance["folderPath"] = asset_name + else: + instance["asset"] = asset_name + instance.set_asset_invalid(False) if task_name is not None: @@ -1282,7 +1290,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget): variants.add(instance.get("variant") or self.unknown_value) families.add(instance.get("family") or self.unknown_value) - asset_name = instance.get("asset") or self.unknown_value + if AYON_SERVER_ENABLED: + asset_name = instance.get("folderPath") or self.unknown_value + else: + asset_name = instance.get("asset") or self.unknown_value task_name = instance.get("task") or "" asset_names.add(asset_name) asset_task_combinations.append((asset_name, task_name)) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index a45d762c73..b83f4dfcaf 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -36,6 +36,7 @@ ASSET_ID_ROLE = QtCore.Qt.UserRole + 1 ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 ASSET_LABEL_ROLE = QtCore.Qt.UserRole + 3 ASSET_UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 +ASSET_PATH_ROLE = QtCore.Qt.UserRole + 5 class AssetsView(TreeViewSpinner, DeselectableTreeView): From 69792a5c64446bf4c5b17333f2da14a2b9368e3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:07:06 +0200 Subject: [PATCH 0810/1224] use 'get_asset_name_identifier' in aftereffecs --- .../plugins/create/workfile_creator.py | 22 ++++++++++++++++--- .../plugins/publish/collect_workfile.py | 6 ++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py index 2e7b9d4a7e..5dc3d6592d 100644 --- a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py +++ b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED import openpype.hosts.aftereffects.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( @@ -43,6 +44,14 @@ class AEWorkfileCreator(AutoCreator): task_name = context.get_current_task_name() host_name = context.host_name + existing_asset_name = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_asset_name = existing_instance.get("folderPath") + + if existing_asset_name is None: + existing_asset_name = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -50,10 +59,13 @@ class AEWorkfileCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -68,7 +80,7 @@ class AEWorkfileCreator(AutoCreator): new_instance.data_to_store()) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -76,6 +88,10 @@ class AEWorkfileCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index dc557f67fc..58d2757840 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -1,6 +1,8 @@ import os import pyblish.api + +from openpype.client import get_asset_name_identifier from openpype.pipeline.create import get_subset_name @@ -48,9 +50,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] + asset_name = get_asset_name_identifier(asset_entity) + instance_data = { "active": True, - "asset": asset_entity["name"], + "asset": asset_name, "task": task, "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], From 18fcfa4a4133ac71873ed5ac67e498891b514f96 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:07:17 +0200 Subject: [PATCH 0811/1224] use 'get_asset_name_identifier' in fusion --- .../fusion/plugins/create/create_workfile.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_workfile.py b/openpype/hosts/fusion/plugins/create/create_workfile.py index 8acaaa172f..8063e56413 100644 --- a/openpype/hosts/fusion/plugins/create/create_workfile.py +++ b/openpype/hosts/fusion/plugins/create/create_workfile.py @@ -1,6 +1,7 @@ from openpype.hosts.fusion.api import ( get_current_comp ) +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, @@ -68,6 +69,15 @@ class FusionWorkfileCreator(AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name + existing_instance_asset = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance.data.get( + "folderPath") + + if not existing_instance_asset: + existing_instance_asset = existing_instance.data.get("asset") + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -75,10 +85,13 @@ class FusionWorkfileCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -91,7 +104,7 @@ class FusionWorkfileCreator(AutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -99,6 +112,9 @@ class FusionWorkfileCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name From 279ab08dfacf6f0916d0162f35c11e72567565e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:07:27 +0200 Subject: [PATCH 0812/1224] use 'get_asset_name_identifier' in celaction --- .../plugins/publish/collect_celaction_instances.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py b/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py index c815c1edd4..875f15fcc5 100644 --- a/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py +++ b/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py @@ -1,6 +1,8 @@ import os import pyblish.api +from openpype.client import get_asset_name_identifier + class CollectCelactionInstances(pyblish.api.ContextPlugin): """ Adds the celaction render instances """ @@ -17,8 +19,10 @@ class CollectCelactionInstances(pyblish.api.ContextPlugin): asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] + asset_name = get_asset_name_identifier(asset_entity) + shared_instance_data = { - "asset": asset_entity["name"], + "asset": asset_name, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], From a5056ea3fbca99c74f63ccafe409bbc650ec612b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:09:43 +0200 Subject: [PATCH 0813/1224] modified maya to follow new usage of 'asset' value --- openpype/hosts/maya/api/plugin.py | 11 ++++++---- .../maya/plugins/create/create_review.py | 7 +++++- .../maya/plugins/create/create_workfile.py | 22 +++++++++++++++---- .../validate_unreal_staticmesh_naming.py | 3 ++- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3b54954c8a..6c8b5b6b78 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -7,6 +7,7 @@ import six from maya import cmds from maya.app.renderSetup.model import renderSetup +from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef, Logger from openpype.settings import get_project_settings from openpype.pipeline import ( @@ -463,14 +464,16 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): # this instance will not have the `instance_node` data yet # until it's been saved/persisted at least once. project_name = self.create_context.get_current_project_name() - + asset_name = self.create_context.get_current_asset_name() instance_data = { - "asset": self.create_context.get_current_asset_name(), "task": self.create_context.get_current_task_name(), "variant": layer.name(), } - asset_doc = get_asset_by_name(project_name, - instance_data["asset"]) + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name + asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( layer.name(), instance_data["task"], diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index f60e2406bc..18d661b186 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -2,6 +2,7 @@ import json from maya import cmds +from openpype import AYON_SERVER_ENABLED from openpype.hosts.maya.api import ( lib, plugin @@ -43,7 +44,11 @@ class CreateReview(plugin.MayaCreator): members = cmds.ls(selection=True) project_name = self.project_name - asset_doc = get_asset_by_name(project_name, instance_data["asset"]) + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + asset_doc = get_asset_by_name(project_name, asset_name) task_name = instance_data["task"] preset = lib.get_capture_preset( task_name, diff --git a/openpype/hosts/maya/plugins/create/create_workfile.py b/openpype/hosts/maya/plugins/create/create_workfile.py index d84753cd7f..74629776af 100644 --- a/openpype/hosts/maya/plugins/create/create_workfile.py +++ b/openpype/hosts/maya/plugins/create/create_workfile.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator -from openpype.client import get_asset_by_name +from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.hosts.maya.api import plugin from maya import cmds @@ -29,16 +30,27 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name + current_instance_asset = None + if current_instance is not None: + if AYON_SERVER_ENABLED: + current_instance_asset = current_instance.get("folderPath") + if not current_instance_asset: + current_instance_asset = current_instance.get("asset") + if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + data.update( self.get_dynamic_data( variant, task_name, asset_doc, @@ -50,14 +62,16 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): ) self._add_instance_to_context(current_instance) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) + asset_name = get_asset_name_identifier(asset_doc) + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name 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 58fa9d02bd..42d3dc3ac8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -102,7 +102,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, cl_r = re.compile(regex_collision) - mesh_name = "{}{}".format(instance.data["asset"], + asset_name = instance.data["assetEntity"]["name"] + mesh_name = "{}{}".format(asset_name, instance.data.get("variant", [])) for obj in collision_set: From 82b1e0b205428dd0496be71bb269bd7d179b67c1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:12:08 +0200 Subject: [PATCH 0814/1224] modified houdini to follow new asset naming --- .../houdini/plugins/create/create_workfile.py | 23 +++++++++--- .../plugins/publish/collect_usd_bootstrap.py | 35 ++++++++++++------- .../plugins/publish/validate_subset_name.py | 10 +++--- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_workfile.py b/openpype/hosts/houdini/plugins/create/create_workfile.py index cc45a6c2a8..04a844bdf5 100644 --- a/openpype/hosts/houdini/plugins/create/create_workfile.py +++ b/openpype/hosts/houdini/plugins/create/create_workfile.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.hosts.houdini.api import plugin from openpype.hosts.houdini.api.lib import read, imprint from openpype.hosts.houdini.api.pipeline import CONTEXT_CONTAINER @@ -30,16 +31,27 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.host_name + current_instance_asset = None + if current_instance is not None: + if AYON_SERVER_ENABLED: + current_instance_asset = current_instance.get("folderPath") + if not current_instance_asset: + current_instance_asset = current_instance.get("asset") + if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderpath"] = asset_name + else: + data["asset"] = asset_name + data.update( self.get_dynamic_data( variant, task_name, asset_doc, @@ -51,15 +63,18 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): ) self._add_instance_to_context(current_instance) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) - current_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name diff --git a/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py b/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py index 14a8e3c056..462cf99b9c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py +++ b/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py @@ -1,6 +1,10 @@ import pyblish.api -from openpype.client import get_subset_by_name, get_asset_by_name +from openpype.client import ( + get_subset_by_name, + get_asset_by_name, + get_asset_name_identifier, +) import openpype.lib.usdlib as usdlib @@ -51,8 +55,9 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): self.log.debug("Add bootstrap for: %s" % bootstrap) project_name = instance.context.data["projectName"] - asset = get_asset_by_name(project_name, instance.data["asset"]) - assert asset, "Asset must exist: %s" % asset + asset_name = instance.data["asset"] + asset_doc = get_asset_by_name(project_name, asset_name) + assert asset_doc, "Asset must exist: %s" % asset_name # Check which are not about to be created and don't exist yet required = {"shot": ["usdShot"], "asset": ["usdAsset"]}.get(bootstrap) @@ -67,19 +72,21 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): required += list(layers) self.log.debug("Checking required bootstrap: %s" % required) - for subset in required: - if self._subset_exists(project_name, instance, subset, asset): + for subset_name in required: + if self._subset_exists( + project_name, instance, subset_name, asset_doc + ): continue self.log.debug( "Creating {0} USD bootstrap: {1} {2}".format( - bootstrap, asset["name"], subset + bootstrap, asset_name, subset_name ) ) - new = instance.context.create_instance(subset) - new.data["subset"] = subset - new.data["label"] = "{0} ({1})".format(subset, asset["name"]) + new = instance.context.create_instance(subset_name) + new.data["subset"] = subset_name + new.data["label"] = "{0} ({1})".format(subset_name, asset_name) new.data["family"] = "usd.bootstrap" new.data["comment"] = "Automated bootstrap USD file." new.data["publishFamilies"] = ["usd"] @@ -91,21 +98,23 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): for key in ["asset"]: new.data[key] = instance.data[key] - def _subset_exists(self, project_name, instance, subset, asset): + def _subset_exists(self, project_name, instance, subset_name, asset_doc): """Return whether subset exists in current context or in database.""" # Allow it to be created during this publish session context = instance.context + + asset_doc_name = get_asset_name_identifier(asset_doc) for inst in context: if ( - inst.data["subset"] == subset - and inst.data["asset"] == asset["name"] + inst.data["subset"] == subset_name + and inst.data["asset"] == asset_doc_name ): return True # Or, if they already exist in the database we can # skip them too. if get_subset_by_name( - project_name, subset, asset["_id"], fields=["_id"] + project_name, subset_name, asset_doc["_id"], fields=["_id"] ): return True return False diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py index bb3648f361..7bed74ebb1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -54,12 +54,13 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) # Check subset name + asset_doc = instance.data["assetEntity"] subset_name = get_subset_name( family=instance.data["family"], variant=instance.data["variant"], task_name=instance.data["task"], - asset_doc=instance.data["assetEntity"], - dynamic_data={"asset": instance.data["asset"]} + asset_doc=asset_doc, + dynamic_data={"asset": asset_doc["name"]} ) if instance.data.get("subset") != subset_name: @@ -76,12 +77,13 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) # Check subset name + asset_doc = instance.data["assetEntity"] subset_name = get_subset_name( family=instance.data["family"], variant=instance.data["variant"], task_name=instance.data["task"], - asset_doc=instance.data["assetEntity"], - dynamic_data={"asset": instance.data["asset"]} + asset_doc=asset_doc, + dynamic_data={"asset": asset_doc["name"]} ) instance.data["subset"] = subset_name From 23c41fe12aedeedd567cd9d290e243a5871c07bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:13:00 +0200 Subject: [PATCH 0815/1224] modified photoshop to follow new 'asset' name usage --- openpype/hosts/photoshop/lib.py | 22 ++++++++++++++++--- .../plugins/create/create_flatten_image.py | 22 +++++++++++++++---- .../plugins/publish/collect_auto_image.py | 3 ++- .../plugins/publish/collect_auto_review.py | 4 +++- .../plugins/publish/collect_auto_workfile.py | 3 ++- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/photoshop/lib.py b/openpype/hosts/photoshop/lib.py index 9f603a70d2..654528410a 100644 --- a/openpype/hosts/photoshop/lib.py +++ b/openpype/hosts/photoshop/lib.py @@ -1,5 +1,6 @@ import re +from openpype import AYON_SERVER_ENABLED import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name from openpype.lib import prepare_template_data @@ -43,6 +44,15 @@ class PSAutoCreator(AutoCreator): asset_name = context.get_current_asset_name() task_name = context.get_current_task_name() host_name = context.host_name + + existing_instance_asset = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance.get("folderPath") + + if not existing_instance_asset: + existing_instance_asset = existing_instance.get("asset") + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -50,10 +60,13 @@ class PSAutoCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -70,7 +83,7 @@ class PSAutoCreator(AutoCreator): new_instance.data_to_store()) elif ( - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -78,7 +91,10 @@ class PSAutoCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index afde77fdb4..942f8f4989 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -1,5 +1,6 @@ from openpype.pipeline import CreatedInstance +from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api from openpype.hosts.photoshop.lib import PSAutoCreator, clean_subset_name @@ -37,6 +38,14 @@ class AutoImageCreator(PSAutoCreator): host_name = context.host_name asset_doc = get_asset_by_name(project_name, asset_name) + existing_instance_asset = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance.get("folderPath") + + if not existing_instance_asset: + existing_instance_asset = existing_instance.get("asset") + if existing_instance is None: subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, @@ -44,9 +53,12 @@ class AutoImageCreator(PSAutoCreator): ) data = { - "asset": asset_name, "task": task_name, } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name if not self.active_on_create: data["active"] = False @@ -62,15 +74,17 @@ class AutoImageCreator(PSAutoCreator): new_instance.data_to_store()) elif ( # existing instance from different context - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) - - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py index 77f1a3e91f..038ae9ff6c 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -27,7 +28,7 @@ class CollectAutoImage(pyblish.api.ContextPlugin): task_name = context.data["anatomyData"]["task"]["name"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) auto_creator = proj_settings.get( "photoshop", {}).get( diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py index 82ba0ac09c..37e9e8bae8 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py @@ -7,6 +7,7 @@ Provides: """ import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -65,7 +66,8 @@ class CollectAutoReview(pyblish.api.ContextPlugin): task_name = context.data["anatomyData"]["task"]["name"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + + asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py index 01dc50af40..be5a641d51 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py @@ -1,6 +1,7 @@ import os import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -69,8 +70,8 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin): task_name = context.data["anatomyData"]["task"]["name"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, variant, From 7c7f9f175c041292e8a11306763a0863cc6bae95 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:13:23 +0200 Subject: [PATCH 0816/1224] modified substance to follow new 'asset' usage --- .../plugins/create/create_workfile.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/substancepainter/plugins/create/create_workfile.py b/openpype/hosts/substancepainter/plugins/create/create_workfile.py index d7f31f9dcf..8aa696f11d 100644 --- a/openpype/hosts/substancepainter/plugins/create/create_workfile.py +++ b/openpype/hosts/substancepainter/plugins/create/create_workfile.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name @@ -41,6 +42,13 @@ class CreateWorkfile(AutoCreator): if instance.creator_identifier == self.identifier ), None) + current_instance_asset = None + if current_instance is not None: + if AYON_SERVER_ENABLED: + current_instance_asset = current_instance.get("folderPath") + if not current_instance_asset: + current_instance_asset = current_instance.get("asset") + if current_instance is None: self.log.info("Auto-creating workfile instance...") asset_doc = get_asset_by_name(project_name, asset_name) @@ -48,22 +56,28 @@ class CreateWorkfile(AutoCreator): variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name current_instance = self.create_instance_in_context(subset_name, data) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) - current_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name From 7d366371a5eec266083721b1ac651813103978fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:17:35 +0200 Subject: [PATCH 0817/1224] modified most of code in traypublisher to follow new asset usage --- openpype/hosts/traypublisher/api/plugin.py | 11 ++++++++--- .../plugins/create/create_colorspace_look.py | 7 ++++++- .../traypublisher/plugins/create/create_editorial.py | 6 +++++- .../plugins/create/create_movie_batch.py | 9 ++++++++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 36e041a32c..14c66fa08f 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -1,7 +1,9 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_assets, get_subsets, get_last_versions, + get_asset_name_identifier, ) from openpype.lib.attribute_definitions import ( FileDef, @@ -114,7 +116,10 @@ class SettingsCreator(TrayPublishCreator): # Fill 'version_to_use' if version control is enabled if self.allow_version_control: - asset_name = data["asset"] + if AYON_SERVER_ENABLED: + asset_name = data["folderPath"] + else: + asset_name = data["asset"] subset_docs_by_asset_id = self._prepare_next_versions( [asset_name], [subset_name]) version = subset_docs_by_asset_id[asset_name].get(subset_name) @@ -162,10 +167,10 @@ class SettingsCreator(TrayPublishCreator): asset_docs = get_assets( self.project_name, asset_names=asset_names, - fields=["_id", "name"] + fields=["_id", "name", "data.parents"] ) asset_names_by_id = { - asset_doc["_id"]: asset_doc["name"] + asset_doc["_id"]: get_asset_name_identifier(asset_doc) for asset_doc in asset_docs } subset_docs = list(get_subsets( diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py index 5628d0973f..ac4c72a0ce 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -6,6 +6,7 @@ production type `ociolook`. All files are published as representation. """ from pathlib import Path +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.lib.attribute_definitions import ( FileDef, EnumDef, TextDef, UISeparatorDef @@ -54,8 +55,12 @@ This creator publishes color space look file (LUT). # this should never happen raise CreatorError("Missing files from representation") + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] asset_doc = get_asset_by_name( - self.project_name, instance_data["asset"]) + self.project_name, asset_name) subset_name = self.get_subset_name( variant=instance_data["variant"], diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 8640500b18..23cf066362 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -1,6 +1,7 @@ import os from copy import deepcopy import opentimelineio as otio +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_asset_by_name, get_project @@ -215,7 +216,10 @@ or updating already created. Publishing will create OTIO file. ] } # Create otio editorial instance - asset_name = instance_data["asset"] + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] asset_doc = get_asset_by_name(self.project_name, asset_name) if pre_create_data["fps"] == "from_selection": diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index 3454b6e135..8fa65c7fff 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -2,6 +2,8 @@ import copy import os import re +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_asset_name_identifier from openpype.lib import ( FileDef, BoolDef, @@ -64,8 +66,13 @@ class BatchMovieCreator(TrayPublishCreator): subset_name, task_name = self._get_subset_and_task( asset_doc, data["variant"], self.project_name) + asset_name = get_asset_name_identifier(asset_doc) + instance_data["task"] = task_name - instance_data["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name # Create new instance new_instance = CreatedInstance(self.family, subset_name, From 8360c321bdf3713df75a7b0225d4cb59ac22b02f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:39:04 +0200 Subject: [PATCH 0818/1224] modified tvpaint to use new 'asset' handling --- .../tvpaint/plugins/create/create_render.py | 54 +++++++++++++++---- .../tvpaint/plugins/create/create_review.py | 21 ++++++-- .../tvpaint/plugins/create/create_workfile.py | 20 +++++-- .../plugins/publish/validate_asset_name.py | 12 ++++- 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index b7a7c208d9..667103432e 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -37,7 +37,8 @@ Todos: import collections from typing import Any, Optional, Union -from openpype.client import get_asset_by_name +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.lib import ( prepare_template_data, AbstractAttrDef, @@ -784,18 +785,25 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): project_name, host_name=self.create_context.host_name, ) + asset_name = get_asset_name_identifier(asset_doc) if existing_instance is not None: - existing_instance["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name return existing_instance instance_data: dict[str, str] = { - "asset": asset_doc["name"], "task": task_name, "family": creator.family, "variant": variant } + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name pre_create_data: dict[str, str] = { "group_id": group_id, "mark_for_review": mark_for_review @@ -820,6 +828,8 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): for layer_name in render_pass["layer_names"]: render_pass_by_layer_name[layer_name] = render_pass + asset_name = get_asset_name_identifier(asset_doc) + for layer in layers: layer_name = layer["name"] variant = layer_name @@ -838,17 +848,25 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): ) if render_pass is not None: - render_pass["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + render_pass["folderPath"] = asset_name + else: + render_pass["asset"] = asset_name + render_pass["task"] = task_name render_pass["subset"] = subset_name continue instance_data: dict[str, str] = { - "asset": asset_doc["name"], "task": task_name, "family": creator.family, "variant": variant } + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name + pre_create_data: dict[str, Any] = { "render_layer_instance_id": render_layer_instance.id, "layer_names": [layer_name], @@ -882,9 +900,13 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): def create(self, subset_name, instance_data, pre_create_data): project_name: str = self.create_context.get_current_project_name() - asset_name: str = instance_data["asset"] + if AYON_SERVER_ENABLED: + asset_name: str = instance_data["folderPath"] + else: + asset_name: str = instance_data["asset"] task_name: str = instance_data["task"] - asset_doc: dict[str, Any] = get_asset_by_name(project_name, asset_name) + asset_doc: dict[str, Any] = get_asset_by_name( + project_name, asset_name) render_layers_by_group_id: dict[int, CreatedInstance] = {} render_passes_by_render_layer_id: dict[int, list[CreatedInstance]] = ( @@ -1061,7 +1083,6 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant, "creator_attributes": { @@ -1073,6 +1094,10 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): self.default_pass_name ) } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name if not self.active_on_create: data["active"] = False @@ -1101,8 +1126,14 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + existing_name = None + if AYON_SERVER_ENABLED: + existing_name = existing_instance.get("folderPath") + if existing_name is None: + existing_name = existing_instance["asset"] + if ( - existing_instance["asset"] != asset_name + existing_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -1114,7 +1145,10 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py index 7bb7510a8e..265cef00ef 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_review.py +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator @@ -33,6 +34,13 @@ class TVPaintReviewCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + existing_asset_name = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_asset_name = existing_instance.get("folderPath") + if existing_asset_name is None: + existing_asset_name = existing_instance.get("asset") + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -43,10 +51,14 @@ class TVPaintReviewCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + if not self.active_on_create: data["active"] = False @@ -59,7 +71,7 @@ class TVPaintReviewCreator(TVPaintAutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -71,6 +83,9 @@ class TVPaintReviewCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py index c3982c0eca..eec0f8483f 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_workfile.py +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator @@ -29,6 +30,13 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + existing_asset_name = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_asset_name = existing_instance.get("folderPath") + if existing_asset_name is None: + existing_asset_name = existing_instance.get("asset") + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -39,10 +47,13 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name new_instance = CreatedInstance( self.family, subset_name, data, self @@ -53,7 +64,7 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -65,6 +76,9 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py index 9347960d3f..dc29e6c278 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py @@ -1,4 +1,5 @@ import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, @@ -24,12 +25,19 @@ class FixAssetNames(pyblish.api.Action): old_instance_items = list_instances() new_instance_items = [] for instance_item in old_instance_items: - instance_asset_name = instance_item.get("asset") + if AYON_SERVER_ENABLED: + instance_asset_name = instance_item.get("folderPath") + else: + instance_asset_name = instance_item.get("asset") + if ( instance_asset_name and instance_asset_name != context_asset_name ): - instance_item["asset"] = context_asset_name + if AYON_SERVER_ENABLED: + instance_item["folderPath"] = context_asset_name + else: + instance_item["asset"] = context_asset_name new_instance_items.append(instance_item) write_instances(new_instance_items) From 5ada46f2a05dc71b621a7cffe077cb9dcff0e997 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:41:37 +0200 Subject: [PATCH 0819/1224] modified some parts of hiero to follow new 'asset' handling --- openpype/hosts/hiero/api/plugin.py | 6 ++---- .../publish/collect_frame_tag_instances.py | 7 ++++++- .../plugins/publish/precollect_instances.py | 1 - .../plugins/publish/precollect_workfile.py | 17 ++++++++++------- .../collect_assetbuilds.py | 18 ++++++++++-------- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 52f96261b2..0e0632e032 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -11,7 +11,6 @@ import qargparse from openpype.settings import get_current_project_settings from openpype.lib import Logger from openpype.pipeline import LoaderPlugin, LegacyCreator -from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline.load import get_representation_path_from_context from . import lib @@ -494,9 +493,8 @@ class ClipLoader: joint `data` key with asset.data dict into the representation """ - asset_name = self.context["representation"]["context"]["asset"] - asset_doc = get_current_project_asset(asset_name) - log.debug("__ asset_doc: {}".format(pformat(asset_doc))) + + asset_doc = self.context["asset"] self.data["assetData"] = asset_doc["data"] def _make_track_item(self, source_bin_item, audio=False): diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py index 982a34efd6..79bf67b336 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -5,6 +5,8 @@ import json import pyblish.api +from openpype.client import get_asset_name_identifier + class CollectFrameTagInstances(pyblish.api.ContextPlugin): """Collect frames from tags. @@ -99,6 +101,9 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): # first collect all available subset tag frames subset_data = {} + context_asset_doc = context.data["assetEntity"] + context_asset_name = get_asset_name_identifier(context_asset_doc) + for tag_data in sequence_tags: frame = int(tag_data["start"]) @@ -115,7 +120,7 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): subset_data[subset] = { "frames": [frame], "format": tag_data["format"], - "asset": context.data["assetEntity"]["name"] + "asset": context_asset_name } return subset_data diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 3f9da2cf60..65b8fed49c 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -178,7 +178,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin): def create_shot_instance(self, context, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - asset = data.get("asset") item = data.get("item") clip_name = item.name() diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 5a66581531..1d6bdc0257 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -7,6 +7,7 @@ from qtpy.QtGui import QPixmap import hiero.ui +from openpype import AYON_SERVER_ENABLED from openpype.hosts.hiero.api.otio import hiero_export @@ -17,9 +18,10 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.491 def process(self, context): + asset_name = context.data["asset"] + if AYON_SERVER_ENABLED: + asset_name = asset_name.split("/")[-1] - asset = context.data["asset"] - subset = "workfile" active_timeline = hiero.ui.activeSequence() project = active_timeline.project() fps = active_timeline.framerate().toFloat() @@ -59,13 +61,14 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): 'files': base_name, "stagingDir": staging_dir, } - + family = "workfile" instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), + "name": "{}_{}".format(asset_name, family), + "asset": context.data["asset"], + # TODO use 'get_subset_name' + "subset": "{}{}".format(asset_name, family.capitalize()), "item": project, - "family": "workfile", + "family": family, "families": [], "representations": [workfile_representation, thumb_representation] } diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py index 767f7c30f7..37370497a5 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py @@ -1,5 +1,6 @@ from pyblish import api -from openpype.client import get_assets + +from openpype.client import get_assets, get_asset_name_identifier class CollectAssetBuilds(api.ContextPlugin): @@ -19,10 +20,13 @@ class CollectAssetBuilds(api.ContextPlugin): def process(self, context): project_name = context.data["projectName"] asset_builds = {} - for asset in get_assets(project_name): - if asset["data"]["entityType"] == "AssetBuild": - self.log.debug("Found \"{}\" in database.".format(asset)) - asset_builds[asset["name"]] = asset + for asset_doc in get_assets(project_name): + if asset_doc["data"].get("entityType") != "AssetBuild": + continue + + asset_name = get_asset_name_identifier(asset_doc) + self.log.debug("Found \"{}\" in database.".format(asset_doc)) + asset_builds[asset_name] = asset_doc for instance in context: if instance.data["family"] != "clip": @@ -50,9 +54,7 @@ class CollectAssetBuilds(api.ContextPlugin): # Collect asset builds. data = {"assetbuilds": []} for name in asset_names: - data["assetbuilds"].append( - asset_builds[name] - ) + data["assetbuilds"].append(asset_builds[name]) self.log.debug( "Found asset builds: {}".format(data["assetbuilds"]) ) From 51bebd0f1e24264e72358d58ef55f77062aaba3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:42:14 +0200 Subject: [PATCH 0820/1224] modified part of flame to follow new 'asset' naming --- .../hosts/flame/plugins/publish/collect_timeline_otio.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py index f8cfa9e963..20ac048986 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.client import get_asset_name_identifier import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export from openpype.pipeline.create import get_subset_name @@ -33,13 +34,15 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): project_settings=context.data["project_settings"] ) + asset_name = get_asset_name_identifier(asset_doc) + # adding otio timeline to context with opfapi.maintained_segment_selection(sequence) as selected_seg: otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": subset_name, - "asset": asset_doc["name"], + "asset": asset_name, "subset": subset_name, "family": "workfile", "families": [] From 9d617db64cac3e06ea373e05511bce4fc0728c19 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:46:35 +0200 Subject: [PATCH 0821/1224] use only folder name to create instance name --- .../hosts/resolve/plugins/publish/precollect_workfile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index a2f3eaed7a..28b2350f01 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -15,6 +15,8 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): def process(self, context): asset = get_current_asset_name() + # AYON compatibility split name and use last piece + _asset_name = asset.split("/")[-1] subset = "workfile" project = rapi.get_current_project() fps = project.GetSetting("timelineFrameRate") @@ -24,9 +26,9 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): otio_timeline = davinci_export.create_otio_timeline(project) instance_data = { - "name": "{}_{}".format(asset, subset), + "name": "{}_{}".format(_asset_name, subset), "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), + "subset": "{}{}".format(_asset_name, subset.capitalize()), "item": project, "family": "workfile", "families": [] From e34c0b9ecd1505657969676bb0d9a062498c6c52 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 24 Oct 2023 18:50:54 +0200 Subject: [PATCH 0822/1224] avoid unnecessary call to database --- 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 8381f81acb..a3d533d3d7 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -1,10 +1,11 @@ import re import uuid +import copy + import qargparse from qtpy import QtWidgets, QtCore from openpype.settings import get_current_project_settings -from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline import ( LegacyCreator, LoaderPlugin, @@ -379,8 +380,8 @@ class ClipLoader: joint `data` key with asset.data dict into the representation """ - asset_name = self.context["representation"]["context"]["asset"] - self.data["assetData"] = get_current_project_asset(asset_name)["data"] + + self.data["assetData"] = copy.deepcopy(self.context["asset"]["data"]) def load(self, files): """Load clip into timeline From 24e20ee12a3bbebff4f7896cdefe201d5b14c279 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 25 Oct 2023 03:25:13 +0000 Subject: [PATCH 0823/1224] [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 e2e3c663af..0bdf2d278a 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.4-nightly.1" +__version__ = "3.17.4-nightly.2" From 439afe4adbbd524b252184cd7c4033b7ad817cb2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 15:21:27 +0800 Subject: [PATCH 0824/1224] narrow down to the 4 image formats support for review --- openpype/hosts/max/plugins/create/create_review.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index bbcdce90b7..4b1149faa1 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -33,10 +33,7 @@ class CreateReview(plugin.MaxCreator): pre_create_data) def get_instance_attr_defs(self): - image_format_enum = [ - "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png", - "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg" - ] + image_format_enum = ["exr", "jpg", "png", "tif"] visual_style_preset_enum = [ "Realistic", "Shaded", "Facets", From 76864ad8f5c886804153496e8f49a02ce86b0cd3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 16:56:34 +0800 Subject: [PATCH 0825/1224] raise publish error in collect review due to the loaded abc camera doesn't support fov attribute properties and narrow down the image format to 3 for reviews --- openpype/hosts/max/plugins/create/create_review.py | 2 +- .../hosts/max/plugins/publish/collect_review.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 4b1149faa1..8052b74f06 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -33,7 +33,7 @@ class CreateReview(plugin.MaxCreator): pre_create_data) def get_instance_attr_defs(self): - image_format_enum = ["exr", "jpg", "png", "tif"] + image_format_enum = ["exr", "jpg", "png"] visual_style_preset_enum = [ "Realistic", "Shaded", "Facets", diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 1f488f8180..beecd391a5 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -5,7 +5,10 @@ import pyblish.api from pymxs import runtime as rt from openpype.lib import BoolDef from openpype.hosts.max.api.lib import get_max_version -from openpype.pipeline.publish import OpenPypePyblishPluginMixin +from openpype.pipeline.publish import ( + OpenPypePyblishPluginMixin, + KnownPublishError +) class CollectReview(pyblish.api.InstancePlugin, @@ -24,7 +27,13 @@ class CollectReview(pyblish.api.InstancePlugin, for node in nodes: if rt.classOf(node) in rt.Camera.classes: camera_name = node.name - focal_length = node.fov + if rt.isProperty(node, "fov"): + focal_length = node.fov + else: + raise KnownPublishError( + "Invalid object found in 'Review' container." + " Only native max Camera supported" + ) creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) From dfd239172cb324d4bff8e5fa89cf39c672abb5d0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 17:20:06 +0800 Subject: [PATCH 0826/1224] do not do normapath in update function --- openpype/hosts/max/plugins/load/load_tycache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py index 1373404274..41ea267c3d 100644 --- a/openpype/hosts/max/plugins/load/load_tycache.py +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -42,7 +42,7 @@ class TyCacheLoader(load.LoaderPlugin): """update the container""" from pymxs import runtime as rt - path = os.path.normpath(get_representation_path(representation)) + path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) update_custom_attribute_data(node, node_list) From 26a575d5941df35cb3e08d45275321cab8cc0819 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 17:34:53 +0800 Subject: [PATCH 0827/1224] improve the code for camera check on the node --- .../max/plugins/publish/collect_review.py | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index beecd391a5..8bf41c13ab 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -22,18 +22,26 @@ class CollectReview(pyblish.api.InstancePlugin, def process(self, instance): nodes = instance.data["members"] - focal_length = None - camera_name = None - for node in nodes: - if rt.classOf(node) in rt.Camera.classes: - camera_name = node.name - if rt.isProperty(node, "fov"): - focal_length = node.fov - else: - raise KnownPublishError( - "Invalid object found in 'Review' container." - " Only native max Camera supported" - ) + def is_camera(node): + is_camera_class = rt.classOf(node) in rt.Camera.classes + return is_camera_class and rt.isProperty(node, "fov") + + # Use first camera in instance + cameras = [node for node in nodes if is_camera(node)] + if cameras: + if len(cameras) > 1: + self.log.warning( + "Found more than one camera in instance, using first " + f"one found: {cameras[0]}" + ) + camera = cameras[0] + camera_name = camera.name + focal_length = camera.fov + else: + raise KnownPublishError( + "Invalid object found in 'Review' container." + " Only native max Camera supported" + ) creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) From 8a2e9af88662ef96daec2906ccd9caa5559f0e96 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 17:35:48 +0800 Subject: [PATCH 0828/1224] hound --- openpype/hosts/max/plugins/publish/collect_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 8bf41c13ab..0d48cc1ff3 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -22,6 +22,7 @@ class CollectReview(pyblish.api.InstancePlugin, def process(self, instance): nodes = instance.data["members"] + def is_camera(node): is_camera_class = rt.classOf(node) in rt.Camera.classes return is_camera_class and rt.isProperty(node, "fov") From 053cc44891343624d73de6a03ed3c560103ff118 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 Oct 2023 11:40:26 +0200 Subject: [PATCH 0829/1224] removed unnecessary line --- openpype/client/server/entities.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index c1e27eabb9..c735c558d5 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -183,7 +183,6 @@ def get_asset_by_name(project_name, asset_name, fields=None): return None - def _folders_query(project_name, con, fields, **kwargs): if fields is None or "tasks" in fields: folders = get_folders_with_tasks( From 7942e33a117e487d5d86c15e8d919fc5dc947ff6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 Oct 2023 12:01:58 +0200 Subject: [PATCH 0830/1224] be expicit about source of asset and folder path --- .../hosts/fusion/plugins/create/create_workfile.py | 14 ++++++-------- .../houdini/plugins/create/create_workfile.py | 12 ++++++------ .../hosts/maya/plugins/create/create_workfile.py | 12 ++++++------ .../plugins/create/create_flatten_image.py | 13 ++++++------- .../plugins/create/create_workfile.py | 12 ++++++------ 5 files changed, 30 insertions(+), 33 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_workfile.py b/openpype/hosts/fusion/plugins/create/create_workfile.py index 8063e56413..4092086ea4 100644 --- a/openpype/hosts/fusion/plugins/create/create_workfile.py +++ b/openpype/hosts/fusion/plugins/create/create_workfile.py @@ -69,14 +69,12 @@ class FusionWorkfileCreator(AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name - existing_instance_asset = None - if existing_instance is not None: - if AYON_SERVER_ENABLED: - existing_instance_asset = existing_instance.data.get( - "folderPath") - - if not existing_instance_asset: - existing_instance_asset = existing_instance.data.get("asset") + if existing_instance is None: + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) diff --git a/openpype/hosts/houdini/plugins/create/create_workfile.py b/openpype/hosts/houdini/plugins/create/create_workfile.py index 04a844bdf5..f8ee68ebc9 100644 --- a/openpype/hosts/houdini/plugins/create/create_workfile.py +++ b/openpype/hosts/houdini/plugins/create/create_workfile.py @@ -31,12 +31,12 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.host_name - current_instance_asset = None - if current_instance is not None: - if AYON_SERVER_ENABLED: - current_instance_asset = current_instance.get("folderPath") - if not current_instance_asset: - current_instance_asset = current_instance.get("asset") + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) diff --git a/openpype/hosts/maya/plugins/create/create_workfile.py b/openpype/hosts/maya/plugins/create/create_workfile.py index 74629776af..7282fc6b8b 100644 --- a/openpype/hosts/maya/plugins/create/create_workfile.py +++ b/openpype/hosts/maya/plugins/create/create_workfile.py @@ -30,12 +30,12 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name - current_instance_asset = None - if current_instance is not None: - if AYON_SERVER_ENABLED: - current_instance_asset = current_instance.get("folderPath") - if not current_instance_asset: - current_instance_asset = current_instance.get("asset") + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 942f8f4989..24be9df0e0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -38,13 +38,12 @@ class AutoImageCreator(PSAutoCreator): host_name = context.host_name asset_doc = get_asset_by_name(project_name, asset_name) - existing_instance_asset = None - if existing_instance is not None: - if AYON_SERVER_ENABLED: - existing_instance_asset = existing_instance.get("folderPath") - - if not existing_instance_asset: - existing_instance_asset = existing_instance.get("asset") + if existing_instance is None: + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] if existing_instance is None: subset_name = self.get_subset_name( diff --git a/openpype/hosts/substancepainter/plugins/create/create_workfile.py b/openpype/hosts/substancepainter/plugins/create/create_workfile.py index 8aa696f11d..c73277e405 100644 --- a/openpype/hosts/substancepainter/plugins/create/create_workfile.py +++ b/openpype/hosts/substancepainter/plugins/create/create_workfile.py @@ -42,12 +42,12 @@ class CreateWorkfile(AutoCreator): if instance.creator_identifier == self.identifier ), None) - current_instance_asset = None - if current_instance is not None: - if AYON_SERVER_ENABLED: - current_instance_asset = current_instance.get("folderPath") - if not current_instance_asset: - current_instance_asset = current_instance.get("asset") + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] if current_instance is None: self.log.info("Auto-creating workfile instance...") From a12d599b442540a46b88212fcdc873ad1c85db46 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Wed, 25 Oct 2023 14:04:00 +0200 Subject: [PATCH 0831/1224] change creator labels --- openpype/hosts/blender/plugins/create/create_action.py | 2 +- openpype/hosts/blender/plugins/create/create_animation.py | 2 +- openpype/hosts/blender/plugins/create/create_camera.py | 2 +- openpype/hosts/blender/plugins/create/create_layout.py | 2 +- openpype/hosts/blender/plugins/create/create_model.py | 2 +- openpype/hosts/blender/plugins/create/create_pointcache.py | 2 +- openpype/hosts/blender/plugins/create/create_review.py | 2 +- openpype/hosts/blender/plugins/create/create_rig.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index d766fce038..e7b689c54e 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -38,7 +38,7 @@ class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 88ae9e5996..8b4214ceda 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -58,7 +58,7 @@ class CreateAnimation(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 026b5739d6..4747e50b2e 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -56,7 +56,7 @@ class CreateCamera(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index f46ae58a43..0c97d57af3 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -55,7 +55,7 @@ class CreateLayout(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 069b78626b..3c8e9c4900 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -55,7 +55,7 @@ class CreateModel(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 3054b81ef5..a40bd5af61 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -38,7 +38,7 @@ class CreatePointcache(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 10a96c94fd..8c9a8d5927 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -54,7 +54,7 @@ class CreateReview(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 8daffe638d..110a9f5c8e 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -55,7 +55,7 @@ class CreateRig(plugin.BlenderCreator): { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, - "label": self.label, + "label": subset_name, "task": get_current_task_name(), "subset": subset_name, "instance_node": instance_node, From 40fe7391b2c8470b0880852bf09e95b706e37ed3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 20:46:28 +0800 Subject: [PATCH 0832/1224] knownPublishError comment tweaks --- openpype/hosts/max/plugins/publish/collect_review.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 0d48cc1ff3..2e3df5b116 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -40,8 +40,9 @@ class CollectReview(pyblish.api.InstancePlugin, focal_length = camera.fov else: raise KnownPublishError( - "Invalid object found in 'Review' container." - " Only native max Camera supported" + "Unable to find a valid camera in 'Review' container." + " Only native max Camera supported. " + f"Found objects: {cameras}" ) creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) From cd6a2941d2410c2ca314589bae4f79182ccd6f41 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 21:15:52 +0800 Subject: [PATCH 0833/1224] comment update for knownpublisherror --- openpype/hosts/max/plugins/publish/collect_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 2e3df5b116..b1d9c2d25e 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -42,7 +42,7 @@ class CollectReview(pyblish.api.InstancePlugin, raise KnownPublishError( "Unable to find a valid camera in 'Review' container." " Only native max Camera supported. " - f"Found objects: {cameras}" + f"Found objects: {nodes}" ) creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) From c3ae2a3a09bdd72824f950682faf418931651ff7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 25 Oct 2023 15:20:28 +0200 Subject: [PATCH 0834/1224] be expicit about source of asset and folder path in photoshop --- openpype/hosts/photoshop/lib.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/photoshop/lib.py b/openpype/hosts/photoshop/lib.py index 654528410a..5c8dff947d 100644 --- a/openpype/hosts/photoshop/lib.py +++ b/openpype/hosts/photoshop/lib.py @@ -45,13 +45,12 @@ class PSAutoCreator(AutoCreator): task_name = context.get_current_task_name() host_name = context.host_name - existing_instance_asset = None - if existing_instance is not None: - if AYON_SERVER_ENABLED: - existing_instance_asset = existing_instance.get("folderPath") - - if not existing_instance_asset: - existing_instance_asset = existing_instance.get("asset") + if existing_instance is None: + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) From 803fb616492f5e34feec7a3a3bdd7e7599dcc8d8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 22:31:27 +0800 Subject: [PATCH 0835/1224] validate loaded plugins tweaks in 3dsmax --- .../plugins/publish/validate_loaded_plugin.py | 69 +++++++++++++++++++ .../plugins/publish/validate_usd_plugin.py | 49 ------------- .../defaults/project_settings/max.json | 5 ++ .../schemas/schema_max_publish.json | 25 +++++++ .../max/server/settings/publishers.py | 18 ++++- server_addon/max/server/version.py | 2 +- 6 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_loaded_plugin.py delete mode 100644 openpype/hosts/max/plugins/publish/validate_usd_plugin.py diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py new file mode 100644 index 0000000000..10cbdf22fb --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""Validator for USD plugin.""" +from pyblish.api import InstancePlugin, ValidatorOrder +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + RepairAction, + OptionalPyblishPluginMixin, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_plugins + + +class ValidateLoadedPlugin(OptionalPyblishPluginMixin, + InstancePlugin): + """Validates if the specific plugin is loaded in 3ds max. + User can add the plugins they want to check through""" + + order = ValidatorOrder + hosts = ["max"] + label = "Validate Loaded Plugin" + optional = True + actions = [RepairAction] + + def get_invalid(self, instance): + """Plugin entry point.""" + invalid = [] + # display all DLL loaded plugins in Max + plugin_info = get_plugins() + project_settings = instance.context.data[ + "project_settings"]["max"]["publish"] + target_plugins = project_settings[ + "ValidateLoadedPlugin"]["plugins_for_check"] + for plugin in target_plugins: + if plugin.lower() not in plugin_info: + invalid.append( + f"Plugin {plugin} not exists in 3dsMax Plugin List.") + for i, _ in enumerate(plugin_info): + if plugin.lower() == rt.pluginManager.pluginDllName(i): + if not rt.pluginManager.isPluginDllLoaded(i): + invalid.append( + f"Plugin {plugin} not loaded.") + return invalid + + def process(self, instance): + invalid_plugins = self.get_invalid(instance) + if invalid_plugins: + bullet_point_invalid_statement = "\n".join( + "- {}".format(invalid) for invalid in invalid_plugins + ) + report = ( + "Required plugins fails to load.\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to load the plugin." + ) + raise PublishValidationError(report, title="Required Plugins unloaded") + + @classmethod + def repair(cls, instance): + plugin_info = get_plugins() + project_settings = instance.context.data[ + "project_settings"]["max"]["publish"] + target_plugins = project_settings[ + "ValidateLoadedPlugin"]["plugins_for_check"] + for plugin in target_plugins: + for i, _ in enumerate(plugin_info): + if plugin == rt.pluginManager.pluginDllName(i): + if not rt.pluginManager.isPluginDllLoaded(i): + rt.pluginManager.loadPluginDll(i) diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py deleted file mode 100644 index 36c4291925..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from pyblish.api import InstancePlugin, ValidatorOrder -from pymxs import runtime as rt - -from openpype.pipeline import ( - OptionalPyblishPluginMixin, - PublishValidationError -) - - -def get_plugins() -> list: - """Get plugin list from 3ds max.""" - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list - - -class ValidateUSDPlugin(OptionalPyblishPluginMixin, - InstancePlugin): - """Validates if USD plugin is installed or loaded in 3ds max.""" - - order = ValidatorOrder - 0.01 - families = ["model"] - hosts = ["max"] - label = "Validate USD Plugin loaded" - optional = True - - def process(self, instance): - """Plugin entry point.""" - - for sc in ValidateUSDPlugin.__subclasses__(): - self.log.info(sc) - - if not self.is_active(instance.data): - return - - plugin_info = get_plugins() - usd_import = "usdimport.dli" - if usd_import not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_import} not found") - usd_export = "usdexport.dle" - if usd_export not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_export} not found") diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..45246fdf2b 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -36,6 +36,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateLoadedPlugin": { + "enabled": false, + "optional": true, + "plugins_for_check": [] } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..4490c5353d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,31 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateLoadedPlugin", + "label": "Validate Loaded Plugin", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "list", + "key": "plugins_for_check", + "label": "Plugins Needed For Check", + "object_type": "text" + } + ] } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a695b85e89..8a28224a07 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -3,6 +3,14 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +class ValidateLoadedPluginModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateLoadedPlugin") + optional: bool = Field(title="Optional") + plugins_for_check: list[str] = Field( + default_factory=list, title="Plugins Needed For Check" + ) + + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") @@ -15,12 +23,20 @@ class PublishersModel(BaseSettingsModel): title="Validate Frame Range", section="Validators" ) - + ValidateLoadedPlugin: ValidateLoadedPluginModel = Field( + default_factory=ValidateLoadedPluginModel, + title="Validate Loaded Plugin" + ) DEFAULT_PUBLISH_SETTINGS = { "ValidateFrameRange": { "enabled": True, "optional": True, "active": True + }, + "ValidateLoadedPlugin": { + "enabled": False, + "optional": True, + "plugins_for_check": [] } } diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From 77776d0943ae8750876f98521a9e493dbcdf584e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 22:53:28 +0800 Subject: [PATCH 0836/1224] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 10cbdf22fb..44343bada2 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -53,7 +53,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, f"{bullet_point_invalid_statement}\n\n" "You can use repair action to load the plugin." ) - raise PublishValidationError(report, title="Required Plugins unloaded") + raise PublishValidationError( + report, title="Required Plugins unloaded") @classmethod def repair(cls, instance): From f35b3508ea737877b71226615fc9747ec0fdbe17 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 25 Oct 2023 17:45:23 +0200 Subject: [PATCH 0837/1224] Fix of no_of_frames (#5819) If it throws exception, no_of_frames wont be available. This approach is used to limit need to decide if published file is image or video-like. Hopefully exception is fast enough and would be still necessary for rare cases of weird video-likes files. --- .../webpublisher/plugins/publish/collect_published_files.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 1416255083..6bb67ef260 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -156,8 +156,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.debug("frameEnd:: {}".format( instance.data["frameEnd"])) except Exception: - self.log.warning("Unable to count frames " - "duration {}".format(no_of_frames)) + self.log.warning("Unable to count frames duration.") instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] From 84abccce4ea26ce45fb0e86d3a895e20356c833f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 26 Oct 2023 09:49:23 +0300 Subject: [PATCH 0838/1224] retrieve settings that missed by merge --- .../houdini/server/settings/publish.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index ab1b71c6bb..6615e34ca5 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -3,6 +3,16 @@ from ayon_server.settings import BaseSettingsModel # Publish Plugins +class CollectRopFrameRangeModel(BaseSettingsModel): + """Collect Frame Range + Disable this if you want the publisher to + ignore start and end handles specified in the + asset data for publish instances + """ + use_asset_handles: bool = Field( + title="Use asset handles") + + class ValidateWorkfilePathsModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") @@ -23,6 +33,11 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): + CollectRopFrameRange: CollectRopFrameRangeModel = Field( + default_factory=CollectRopFrameRangeModel, + title="Collect Rop Frame Range.", + section="Collectors" + ) ValidateContainers: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Latest Containers.", @@ -45,6 +60,9 @@ class PublishPluginsModel(BaseSettingsModel): DEFAULT_HOUDINI_PUBLISH_SETTINGS = { + "CollectRopFrameRange": { + "use_asset_handles": True + }, "ValidateContainers": { "enabled": True, "optional": True, From 64be3b09830f4a793ae9219d071ea044a46e1d2c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 26 Oct 2023 09:49:57 +0300 Subject: [PATCH 0839/1224] bump minor version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 0a8da88258..01ef12070d 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.2.6" From a43b842097b48924345cb8be98f8ca380a2b73a5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 15:49:28 +0800 Subject: [PATCH 0840/1224] add missing codes for switching on/off the loaded plugin validator --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 44343bada2..0090c69269 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -24,6 +24,9 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, def get_invalid(self, instance): """Plugin entry point.""" + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return invalid = [] # display all DLL loaded plugins in Max plugin_info = get_plugins() From cb4dd2559b9d51469870a3962cf69435f7d290b4 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 26 Oct 2023 10:44:34 +0200 Subject: [PATCH 0841/1224] wip create blend scene create render --- .../plugins/create/create_blendScene.py | 41 +++++++++++++++---- .../blender/plugins/create/create_render.py | 35 ++++++++++++---- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 63bcf212ff..ee8e52d3c5 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -4,7 +4,10 @@ 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 +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CreateBlendScene(plugin.Creator): @@ -15,12 +18,18 @@ class CreateBlendScene(plugin.Creator): family = "blendScene" icon = "cubes" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) + mti = ops.MainThreadItem( + self._process, subset_name, instance_data, pre_create_data + ) ops.execute_in_main_thread(mti) - def _process(self): + def _process( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: @@ -28,14 +37,28 @@ class CreateBlendScene(plugin.Creator): 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 = instance_data.get("asset") + name = plugin.asset_name(asset, subset_name) asset_group = bpy.data.objects.new(name=name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) + + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name + } + + instance_data.update( + { + "id": "publish.avalon.instance", + "creator_identifier": self.identifier, + "label": subset_name, + "task": get_current_task_name(), + "subset": subset_name, + "instance_node": instance_node, + } + ) + + lib.imprint(asset_group, instance_data) # Add selected objects to instance if (self.options or {}).get("useSelection"): diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index f938a21808..ab3119b32e 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -4,10 +4,13 @@ import bpy 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 +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) -class CreateRenderlayer(plugin.Creator): +class CreateRenderlayer(plugin.BlenderCreator): """Single baked camera""" name = "renderingMain" @@ -15,7 +18,9 @@ class CreateRenderlayer(plugin.Creator): family = "render" icon = "eye" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: @@ -23,15 +28,29 @@ class CreateRenderlayer(plugin.Creator): 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 = instance_data.get("asset") + name = plugin.asset_name(asset, subset_name) 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) + + asset_group[AVALON_PROPERTY] = instance_node = { + "name": asset_group.name + } + + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": subset_name, + "task": get_current_task_name(), + "subset": subset_name, + "instance_node": instance_node, + } + ) + + lib.imprint(asset_group, instance_data) prepare_rendering(asset_group) except Exception: From 5d87d08ab83b81815e6bc47bddcd7300a30fcc60 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:04:00 +0800 Subject: [PATCH 0842/1224] clean up the code of validate loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 0090c69269..a8bdf7f903 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from pyblish.api import InstancePlugin, ValidatorOrder +"""Validator for Loaded Plugin.""" +from pyblish.api import ContextPlugin, ValidatorOrder from pymxs import runtime as rt from openpype.pipeline.publish import ( - RepairAction, + RepairContextAction, OptionalPyblishPluginMixin, PublishValidationError ) @@ -12,7 +12,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - InstancePlugin): + ContextPlugin): """Validates if the specific plugin is loaded in 3ds max. User can add the plugins they want to check through""" @@ -20,29 +20,38 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, hosts = ["max"] label = "Validate Loaded Plugin" optional = True - actions = [RepairAction] + actions = [RepairContextAction] - def get_invalid(self, instance): + def get_invalid(self, context): """Plugin entry point.""" - if not self.is_active(instance.data): + if not self.is_active(context.data): self.log.debug("Skipping Validate Loaded Plugin...") return invalid = [] - # display all DLL loaded plugins in Max - plugin_info = get_plugins() - project_settings = instance.context.data[ - "project_settings"]["max"]["publish"] - target_plugins = project_settings[ - "ValidateLoadedPlugin"]["plugins_for_check"] - for plugin in target_plugins: - if plugin.lower() not in plugin_info: + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate(\ + get_plugins()) + } + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + for plugin in required_plugins: + plugin_name = plugin.lower() + + plugin_index = available_plugins.get(plugin_name) + + if plugin_index is None: invalid.append( - f"Plugin {plugin} not exists in 3dsMax Plugin List.") - for i, _ in enumerate(plugin_info): - if plugin.lower() == rt.pluginManager.pluginDllName(i): - if not rt.pluginManager.isPluginDllLoaded(i): - invalid.append( - f"Plugin {plugin} not loaded.") + f"Plugin {plugin} not exists in 3dsMax Plugin List." + ) + continue + + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + invalid.append( + f"Plugin {plugin} not loaded.") + return invalid def process(self, instance): @@ -60,14 +69,18 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, report, title="Required Plugins unloaded") @classmethod - def repair(cls, instance): - plugin_info = get_plugins() - project_settings = instance.context.data[ - "project_settings"]["max"]["publish"] - target_plugins = project_settings[ - "ValidateLoadedPlugin"]["plugins_for_check"] - for plugin in target_plugins: - for i, _ in enumerate(plugin_info): - if plugin == rt.pluginManager.pluginDllName(i): - if not rt.pluginManager.isPluginDllLoaded(i): - rt.pluginManager.loadPluginDll(i) + def repair(cls, context): + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + for plugin in required_plugins: + plugin_name = plugin.lower() + plugin_index = available_plugins.get(plugin_name) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) From 6c24e55d9697bd28d6d7e7cac813f2d0756aa6a8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:05:10 +0800 Subject: [PATCH 0843/1224] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index a8bdf7f903..564cfd0e67 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -30,7 +30,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, invalid = [] # get all DLL loaded plugins in Max and their plugin index available_plugins = { - plugin_name.lower(): index for index, plugin_name in enumerate(\ + plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } required_plugins = ( From b0a12848b92a825e7d979fa9c63416310ec6d528 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:47:40 +0800 Subject: [PATCH 0844/1224] clean up code and add condition to make sure the plugin not erroring out during validation --- .../plugins/publish/validate_loaded_plugin.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 564cfd0e67..49f0f3041b 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -18,7 +18,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, order = ValidatorOrder hosts = ["max"] - label = "Validate Loaded Plugin" + label = "Validate Loaded Plugins" optional = True actions = [RepairContextAction] @@ -27,16 +27,23 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not self.is_active(context.data): self.log.debug("Skipping Validate Loaded Plugin...") return + + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + + if not required_plugins: + return + invalid = [] + # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] - ) + for plugin in required_plugins: plugin_name = plugin.lower() @@ -49,8 +56,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append( - f"Plugin {plugin} not loaded.") + invalid.append(f"Plugin {plugin} not loaded.") return invalid @@ -82,5 +88,10 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, for plugin in required_plugins: plugin_name = plugin.lower() plugin_index = available_plugins.get(plugin_name) + + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {plugin}") + continue + if not rt.pluginManager.isPluginDllLoaded(plugin_index): rt.pluginManager.loadPluginDll(plugin_index) From a8c4c05b7329b6ccf22309f14aa3990f18b96844 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:58:03 +0800 Subject: [PATCH 0845/1224] Docstring edit --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 49f0f3041b..9602d0f313 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -14,7 +14,8 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, ContextPlugin): """Validates if the specific plugin is loaded in 3ds max. - User can add the plugins they want to check through""" + Studio Admin(s) can add the plugins they want to check in validation + via studio defined project settings""" order = ValidatorOrder hosts = ["max"] From ca2ff805910510a6ecf75e0ae233b8b818665924 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 Oct 2023 12:43:13 +0200 Subject: [PATCH 0846/1224] nuke: updating colorspace defaults --- .../defaults/project_settings/nuke.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 1cadedd797..20df0ad5c2 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -19,16 +19,16 @@ "rules": {} }, "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "colorManagement": "Nuke", + "colorManagement": "OCIO", "OCIO_config": "nuke-default", - "workingSpaceLUT": "linear", - "monitorLut": "sRGB" + "workingSpaceLUT": "scene_linear", + "monitorLut": "sRGB (default)" }, "nodes": { "requiredNodes": [ @@ -76,7 +76,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -129,7 +129,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -177,7 +177,7 @@ { "type": "text", "name": "colorspace", - "value": "sRGB" + "value": "texture_paint" }, { "type": "bool", @@ -193,7 +193,7 @@ "inputs": [ { "regex": "(beauty).*(?=.exr)", - "colorspace": "linear" + "colorspace": "scene_linear" } ] } From 71014fca0b42e490c2ceedf94fc90648ca16808a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 13:34:18 +0200 Subject: [PATCH 0847/1224] hide multivalue widget by default --- openpype/tools/attribute_defs/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 91b5b229de..8957f2b19d 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -298,6 +298,7 @@ class NumberAttrWidget(_BaseAttrDefWidget): input_widget.installEventFilter(self) multisel_widget = ClickableLineEdit("< Multiselection >", self) + multisel_widget.setVisible(False) input_widget.valueChanged.connect(self._on_value_change) multisel_widget.clicked.connect(self._on_multi_click) From 2f4844613e2e8051bc54a76154e2a7abf211306d Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 26 Oct 2023 13:13:17 +0100 Subject: [PATCH 0848/1224] Use constant to define Asset path --- openpype/hosts/unreal/api/pipeline.py | 1 + openpype/hosts/unreal/plugins/load/load_alembic_animation.py | 2 +- openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py | 3 ++- openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py | 3 ++- openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py | 3 ++- openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py | 3 ++- openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py | 3 ++- openpype/hosts/unreal/plugins/load/load_uasset.py | 2 +- openpype/hosts/unreal/plugins/load/load_yeticache.py | 2 +- 9 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 0bb19ec601..f2d7b5f73e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -30,6 +30,7 @@ import unreal # noqa logger = logging.getLogger("openpype.hosts.unreal") AYON_CONTAINERS = "AyonContainers" +AYON_ASSET_DIR = "/Game/Ayon/Assets" CONTEXT_CONTAINER = "Ayon/context.json" UNREAL_VERSION = semver.VersionInfo( *os.getenv("AYON_UNREAL_VERSION").split(".") diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py index 1d60b63f9a..0328d2ae9f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py @@ -69,7 +69,7 @@ class AnimationAlembicLoader(plugin.Loader): """ # Create directory for asset and ayon container - root = "/Game/Ayon/Assets" + root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" if asset: diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index e64a5654a1..ec9c52b9fb 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, create_container, imprint, ) @@ -24,7 +25,7 @@ class PointCacheAlembicLoader(plugin.Loader): icon = "cube" color = "orange" - root = "/Game/Ayon/Assets" + root = AYON_ASSET_DIR @staticmethod def get_task( diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 03695bb93b..8ebd9a82b6 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, create_container, imprint, ) @@ -23,7 +24,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" - root = "/Game/Ayon/Assets" + root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 7640ecfa9e..a5a8730732 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, create_container, imprint, ) @@ -23,7 +24,7 @@ class SkeletalMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" - root = "/Game/Ayon/Assets" + root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace): diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index a3ea2a2231..019a95a9bf 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, create_container, imprint, ) @@ -23,7 +24,7 @@ class StaticMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" - root = "/Game/Ayon/Assets" + root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index 44d7ca631e..66088d793c 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, create_container, imprint, ) @@ -23,7 +24,7 @@ class StaticMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" - root = "/Game/Ayon/Assets" + root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace): diff --git a/openpype/hosts/unreal/plugins/load/load_uasset.py b/openpype/hosts/unreal/plugins/load/load_uasset.py index 88aaac41e8..dfd92d2fe5 100644 --- a/openpype/hosts/unreal/plugins/load/load_uasset.py +++ b/openpype/hosts/unreal/plugins/load/load_uasset.py @@ -41,7 +41,7 @@ class UAssetLoader(plugin.Loader): """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" + root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py index 22f5029bac..780ed7c484 100644 --- a/openpype/hosts/unreal/plugins/load/load_yeticache.py +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -86,7 +86,7 @@ class YetiLoader(plugin.Loader): raise RuntimeError("Groom plugin is not activated.") # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" + root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" From 3b1b59916662b528ba7fee1e952511f32a615284 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 Oct 2023 14:18:11 +0200 Subject: [PATCH 0849/1224] updating create ayon addon script - adding argparse module - adding `output` for targeting to specfic folder (ayon-docker/addons) - adding `dont-clear-output` for avoiding clearing already created folders and versions in addons --- server_addon/create_ayon_addons.py | 64 +++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 61dbd5c8d9..47711244c3 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -3,6 +3,7 @@ import sys import re import json import shutil +import argparse import zipfile import platform import collections @@ -185,8 +186,8 @@ def create_openpype_package( addon_output_dir = output_dir / "openpype" / addon_version private_dir = addon_output_dir / "private" # Make sure dir exists - addon_output_dir.mkdir(parents=True) - private_dir.mkdir(parents=True) + addon_output_dir.mkdir(parents=True, exist_ok=True) + private_dir.mkdir(parents=True, exist_ok=True) # Copy version shutil.copy(str(version_path), str(addon_output_dir)) @@ -268,19 +269,29 @@ def create_addon_package( ) -def main(create_zip=True, keep_source=False): +def main(output_dir=None, skip_zip=True, keep_source=False, clear_output_dir=False): current_dir = Path(os.path.dirname(os.path.abspath(__file__))) root_dir = current_dir.parent - output_dir = current_dir / "packages" + create_zip = not skip_zip + + output_dir = Path(output_dir) if output_dir else current_dir / "packages" + print("Current directory:", current_dir) + print("Root directory:", root_dir) + print("Skip zip:", skip_zip) + print("Clear output dir:", clear_output_dir) + print("Keep source:", keep_source) print("Package creation started...") + print(f"Output directory: {output_dir}") # Make sure package dir is empty - if output_dir.exists(): + if output_dir.exists() and clear_output_dir: shutil.rmtree(str(output_dir)) + # Make sure output dir is created - output_dir.mkdir(parents=True) + output_dir.mkdir(parents=True, exist_ok=True) for addon_dir in current_dir.iterdir(): + print(f"Processing addon: {addon_dir.name}") if not addon_dir.is_dir(): continue @@ -303,6 +314,41 @@ def main(create_zip=True, keep_source=False): if __name__ == "__main__": - create_zip = "--skip-zip" not in sys.argv - keep_sources = "--keep-sources" in sys.argv - main(create_zip, keep_sources) + parser = argparse.ArgumentParser() + parser.add_argument( + "--skip-zip", + dest="skip_zip", + action="store_true", + help=( + "Skip zipping server package and create only" + " server folder structure." + ) + ) + parser.add_argument( + "--keep-sources", + dest="keep_sources", + action="store_true", + help=( + "Keep folder structure when server package is created." + ) + ) + parser.add_argument( + "-o", "--output", + dest="output_dir", + default=None, + help=( + "Directory path where package will be created" + " (Will be purged if already exists!)" + ) + ) + parser.add_argument( + "-c", "--dont-clear-output", + dest="clear_output_dir", + action="store_false", + help=( + "Clear output directory before creating packages." + ) + ) + + args = parser.parse_args(sys.argv[1:]) + main(args.output_dir, args.skip_zip, args.keep_sources, args.clear_output_dir) From ad27d4b1cd6b331e3a0f1200440d73d2bae11efc Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 26 Oct 2023 12:26:01 +0000 Subject: [PATCH 0850/1224] [Automated] Release --- CHANGELOG.md | 268 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 270 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58428ab4d3..7432b33e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,274 @@ # Changelog +## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.3...3.17.4) + +### **🆕 New features** + + +
+Add Support for Husk-AYON Integration #5816 + +This draft pull request introduces support for integrating Husk with AYON within the OpenPype repository. + + +___ + +
+ + +
+Push to project tool: Prepare push to project tool for AYON #5770 + +Cloned Push to project tool for AYON and modified it. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Max: tycache family support #5624 + +Tycache family supports for Tyflow Plugin in Max + + +___ + +
+ + +
+Unreal: Changed behaviour for updating assets #5670 + +Changed how assets are updated in Unreal. + + +___ + +
+ + +
+Unreal: Improved error reporting for Sequence Frame Validator #5730 + +Improved error reporting for Sequence Frame Validator. + + +___ + +
+ + +
+Max: Setting tweaks on Review Family #5744 + +- Bug fix of not being able to publish the preferred visual style when creating preview animation +- Exposes the parameters after creating instance +- Add the Quality settings and viewport texture settings for preview animation +- add use selection for create review + + +___ + +
+ + +
+Max: Add families with frame range extractions back to the frame range validator #5757 + +In 3dsMax, there are some instances which exports the files in frame range but not being added to the optional frame range validator. In this PR, these instances would have the optional frame range validators to allow users to check if frame range aligns with the context data from DB.The following families have been added to have optional frame range validator: +- maxrender +- review +- camera +- redshift proxy +- pointcache +- point cloud(tyFlow PRT) + + +___ + +
+ + +
+TimersManager: Use available data to get context info #5804 + +Get context information from pyblish context data instead of using `legacy_io`. + + +___ + +
+ + +
+Chore: Removed unused variable from `AbstractCollectRender` #5805 + +Removed unused `_asset` variable from `RenderInstance`. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Bugfix/houdini: wrong frame calculation with handles #5698 + +This PR make collect plugins to consider `handleStart` and `handleEnd` when collecting frame range it affects three parts: +- get frame range in collect plugins +- expected file in render plugins +- submit houdini job deadline plugin + + +___ + +
+ + +
+Nuke: ayon server settings improvements #5746 + +Nuke settings were not aligned with OpenPype settings. Also labels needed to be improved. + + +___ + +
+ + +
+Blender: Fix pointcache family and fix alembic extractor #5747 + +Fixed `pointcache` family and fixed behaviour of the alembic extractor. + + +___ + +
+ + +
+AYON: Remove 'shotgun_api3' from dependencies #5803 + +Removed `shotgun_api3` dependency from openpype dependencies for AYON launcher. The dependency is already defined in shotgrid addon and change of version causes clashes. + + +___ + +
+ + +
+Chore: Fix typo in filename #5807 + +Move content of `contants.py` into `constants.py`. + + +___ + +
+ + +
+Chore: Create context respects instance changes #5809 + +Fix issue with unrespected change propagation in `CreateContext`. All successfully saved instances are marked as saved so they have no changes. Origin data of an instance are explicitly not handled directly by the object but by the attribute wrappers. + + +___ + +
+ + +
+Blender: Fix tools handling in AYON mode #5811 + +Skip logic in `before_window_show` in blender when in AYON mode. Most of the stuff called there happes on show automatically. + + +___ + +
+ + +
+Blender: Include Grease Pencil in review and thumbnails #5812 + +Include Grease Pencil in review and thumbnails. + + +___ + +
+ + +
+Workfiles tool AYON: Fix double click of workfile #5813 + +Fix double click on workfiles in workfiles tool to open the file. + + +___ + +
+ + +
+Webpublisher: removal of usage of no_of_frames in error message #5819 + +If it throws exception, `no_of_frames` value wont be available, so it doesn't make sense to log it. + + +___ + +
+ + +
+Attribute Defs: Hide multivalue widget in Number by default #5821 + +Fixed default look of `NumberAttrWidget` by hiding its multiselection widget. + + +___ + +
+ +### **Merged pull requests** + + +
+Corrected a typo in Readme.md (Top -> To) #5800 + + +___ + +
+ + +
+Photoshop: Removed redundant copy of extension.zxp #5802 + +`extension.zxp` shouldn't be inside of extension folder. + + +___ + +
+ + + + ## [3.17.3](https://github.com/ynput/OpenPype/tree/3.17.3) diff --git a/openpype/version.py b/openpype/version.py index 0bdf2d278a..4c58a5098f 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.4-nightly.2" +__version__ = "3.17.4" diff --git a/pyproject.toml b/pyproject.toml index 3803e4714e..633dafece1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.3" # OpenPype +version = "3.17.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 9c7ce75eea842ed60e53b70ba89a466c10049116 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Oct 2023 12:27:07 +0000 Subject: [PATCH 0851/1224] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3c126048da..b92061f39a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,8 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.4 + - 3.17.4-nightly.2 - 3.17.4-nightly.1 - 3.17.3 - 3.17.3-nightly.2 @@ -133,8 +135,6 @@ body: - 3.15.1-nightly.2 - 3.15.1-nightly.1 - 3.15.0 - - 3.15.0-nightly.1 - - 3.14.11-nightly.4 validations: required: true - type: dropdown From 1ecd96acf6acb98f9fb27aa70a345ad5014343b9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 21:18:28 +0800 Subject: [PATCH 0852/1224] use context.data instead of instance data --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 9602d0f313..69f72ccf1d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -61,8 +61,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, return invalid - def process(self, instance): - invalid_plugins = self.get_invalid(instance) + def process(self, context): + invalid_plugins = self.get_invalid(context) if invalid_plugins: bullet_point_invalid_statement = "\n".join( "- {}".format(invalid) for invalid in invalid_plugins From 5b18acadc0c76e7bd51f5fe8a6b1b93486a0e79e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 21:33:31 +0800 Subject: [PATCH 0853/1224] Implement ValidateAttributes in 3dsMax --- .../plugins/publish/validate_attributes.py | 62 +++++++++++++++++++ openpype/settings/ayon_settings.py | 9 +++ .../defaults/project_settings/max.json | 4 ++ .../schemas/schema_max_publish.json | 19 ++++++ .../max/server/settings/publishers.py | 36 ++++++++++- 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_attributes.py diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py new file mode 100644 index 0000000000..e98e73de06 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Validator for Attributes.""" +from pyblish.api import ContextPlugin, ValidatorOrder +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError, + RepairContextAction +) + + +class ValidateAttributes(OptionalPyblishPluginMixin, + ContextPlugin): + """Validates attributes are consistent in 3ds max.""" + + order = ValidatorOrder + hosts = ["max"] + label = "Attributes" + actions = [RepairContextAction] + optional = True + + @classmethod + def get_invalid(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + if not attributes: + return + + invalid_attributes = [key for key, value in attributes.items() + if rt.Execute(attributes[key]) != value] + + return invalid_attributes + + def process(self, context): + if not self.is_active(context.data): + self.log.debug("Skipping Validate Attributes...") + return + invalid_attributes = self.get_invalid(context) + if invalid_attributes: + bullet_point_invalid_statement = "\n".join( + "- {}".format(invalid) for invalid in invalid_attributes + ) + report = ( + "Required Attribute(s) have invalid value(s).\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to fix it." + ) + raise PublishValidationError( + report, title="Invalid Value(s) for Required Attribute(s)") + + @classmethod + def repair(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + invalid_attribute_keys = cls.get_invalid(context) + for key in invalid_attribute_keys: + attributes[key] = rt.Execute(attributes[key]) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..a31c8a04e0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -639,6 +639,15 @@ def _convert_3dsmax_project_settings(ayon_settings, output): for item in point_cloud_attribute } ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + # --- Publish (START) --- + ayon_publish = ayon_max["publish"] + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes output["max"] = ayon_max diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..24a87020bb 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -36,6 +36,10 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateAttributes": { + "enabled": false, + "attributes": {} } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..c3b56bae5e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,25 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateAttributes", + "label": "ValidateAttributes", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "raw-json", + "key": "attributes", + "label": "Attributes" + } + ] } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a695b85e89..df8412391a 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,6 +1,30 @@ -from pydantic import Field +import json +from pydantic import Field, validator from ayon_server.settings import BaseSettingsModel +from ayon_server.exceptions import BadRequestException + + +class ValidateAttributesModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateAttributes") + attributes: str = Field( + "{}", title="Attributes", widget="textarea") + + @validator("attributes") + def validate_json(cls, value): + if not value.strip(): + return "{}" + try: + converted_value = json.loads(value) + success = isinstance(converted_value, dict) + except json.JSONDecodeError: + success = False + + if not success: + raise BadRequestException( + "The attibutes can't be parsed as json object" + ) + return value class BasicValidateModel(BaseSettingsModel): @@ -15,6 +39,10 @@ class PublishersModel(BaseSettingsModel): title="Validate Frame Range", section="Validators" ) + ValidateAttributes: ValidateAttributesModel = Field( + default_factory=ValidateAttributesModel, + title="Validate Attributes" + ) DEFAULT_PUBLISH_SETTINGS = { @@ -22,5 +50,9 @@ DEFAULT_PUBLISH_SETTINGS = { "enabled": True, "optional": True, "active": True - } + }, + "ValidateAttributes": { + "enabled": False, + "attributes": "{}" + }, } From a6e72e708b5853f42aa99a18a57554ee2494d5ec Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 17:03:42 +0200 Subject: [PATCH 0854/1224] removed debugging prints --- server_addon/create_ayon_addons.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 47711244c3..711dc5d00a 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -274,24 +274,17 @@ def main(output_dir=None, skip_zip=True, keep_source=False, clear_output_dir=Fal root_dir = current_dir.parent create_zip = not skip_zip - output_dir = Path(output_dir) if output_dir else current_dir / "packages" - print("Current directory:", current_dir) - print("Root directory:", root_dir) - print("Skip zip:", skip_zip) - print("Clear output dir:", clear_output_dir) - print("Keep source:", keep_source) - print("Package creation started...") - print(f"Output directory: {output_dir}") # Make sure package dir is empty if output_dir.exists() and clear_output_dir: shutil.rmtree(str(output_dir)) + print("Package creation started...") + print(f"Output directory: {output_dir}") + # Make sure output dir is created output_dir.mkdir(parents=True, exist_ok=True) - for addon_dir in current_dir.iterdir(): - print(f"Processing addon: {addon_dir.name}") if not addon_dir.is_dir(): continue From afc980efb30207f510416ab3b8be2a7938a8a1d6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 17:07:24 +0200 Subject: [PATCH 0855/1224] small cleanup of the code and add option to limit addons creation --- server_addon/create_ayon_addons.py | 40 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 711dc5d00a..2f7be760f3 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -185,6 +185,9 @@ def create_openpype_package( addon_output_dir = output_dir / "openpype" / addon_version private_dir = addon_output_dir / "private" + if addon_output_dir.exists(): + shutil.rmtree(str(addon_output_dir)) + # Make sure dir exists addon_output_dir.mkdir(parents=True, exist_ok=True) private_dir.mkdir(parents=True, exist_ok=True) @@ -269,13 +272,22 @@ def create_addon_package( ) -def main(output_dir=None, skip_zip=True, keep_source=False, clear_output_dir=False): +def main( + output_dir=None, + skip_zip=True, + keep_source=False, + clear_output_dir=False, + addons=None, +): current_dir = Path(os.path.dirname(os.path.abspath(__file__))) root_dir = current_dir.parent create_zip = not skip_zip + if output_dir: + output_dir = Path(output_dir) + else: + output_dir = current_dir / "packages" - # Make sure package dir is empty if output_dir.exists() and clear_output_dir: shutil.rmtree(str(output_dir)) @@ -288,6 +300,9 @@ def main(output_dir=None, skip_zip=True, keep_source=False, clear_output_dir=Fal if not addon_dir.is_dir(): continue + if addons and addon_dir.name not in addons: + continue + server_dir = addon_dir / "server" if not server_dir.exists(): continue @@ -335,13 +350,26 @@ if __name__ == "__main__": ) ) parser.add_argument( - "-c", "--dont-clear-output", + "-c", "--clear-output-dir", dest="clear_output_dir", - action="store_false", + action="store_true", help=( - "Clear output directory before creating packages." + "Clear output directory before package creation." ) ) + parser.add_argument( + "-a", + "--addon", + dest="addons", + action="append", + help="Limit addon creation to given addon name", + ) args = parser.parse_args(sys.argv[1:]) - main(args.output_dir, args.skip_zip, args.keep_sources, args.clear_output_dir) + main( + args.output_dir, + args.skip_zip, + args.keep_sources, + args.clear_output_dir, + args.addons, + ) From ae2c4bd5548c6ba81e7937320b0b91554ea614cd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 Oct 2023 17:09:34 +0200 Subject: [PATCH 0856/1224] nuke: aligning server addon settings with openpype --- server_addon/nuke/server/settings/imageio.py | 16 ++++++++-------- server_addon/nuke/server/version.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index 15ccd4e89a..19ad5ff24a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -213,16 +213,16 @@ class ImageIOSettings(BaseSettingsModel): DEFAULT_IMAGEIO_SETTINGS = { "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "color_management": "Nuke", + "color_management": "OCIO", "native_ocio_config": "nuke-default", - "working_space": "linear", - "thumbnail_space": "sRGB", + "working_space": "scene_linear", + "thumbnail_space": "sRGB (default)", }, "nodes": { "required_nodes": [ @@ -269,7 +269,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -321,7 +321,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -368,7 +368,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "sRGB" + "text": "texture_paint" }, { "type": "boolean", diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" From b6694606876eba68f3e0cb24928f93f7093e1c70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:06:47 +0200 Subject: [PATCH 0857/1224] Removed unnecessary condition. --- openpype/client/server/entities.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index c735c558d5..fcb5ec2383 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -226,12 +226,11 @@ def get_assets( new_asset_names = set() folder_paths = set() - if asset_names: - for name in asset_names: - if "/" in name: - folder_paths.add(name) - else: - new_asset_names.add(name) + for name in asset_names: + if "/" in name: + folder_paths.add(name) + else: + new_asset_names.add(name) if folder_paths: for folder in _folders_query( From 130693b798be8c53e18ec02bcacde7b8b6f45fdd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 18:24:42 +0200 Subject: [PATCH 0858/1224] use 'AYON_SERVER_ENABLED' in resolve plugin --- .../plugins/publish/precollect_workfile.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index 28b2350f01..39c28e29f5 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -1,6 +1,7 @@ import pyblish.api from pprint import pformat +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name from openpype.hosts.resolve import api as rapi from openpype.hosts.resolve.otio import davinci_export @@ -13,10 +14,13 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + current_asset = get_current_asset_name() + if AYON_SERVER_ENABLED: + # AYON compatibility split name and use last piece + asset_name = current_asset.split("/")[-1] + else: + asset_name = current_asset - asset = get_current_asset_name() - # AYON compatibility split name and use last piece - _asset_name = asset.split("/")[-1] subset = "workfile" project = rapi.get_current_project() fps = project.GetSetting("timelineFrameRate") @@ -26,9 +30,9 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): otio_timeline = davinci_export.create_otio_timeline(project) instance_data = { - "name": "{}_{}".format(_asset_name, subset), - "asset": asset, - "subset": "{}{}".format(_asset_name, subset.capitalize()), + "name": "{}_{}".format(asset_name, subset), + "asset": current_asset, + "subset": "{}{}".format(asset_name, subset.capitalize()), "item": project, "family": "workfile", "families": [] From e920c014307e0fcb543f129b6e72b4ba49433cfb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 18:41:47 +0200 Subject: [PATCH 0859/1224] unify conditions --- .../hosts/tvpaint/plugins/create/create_review.py | 12 ++++++------ .../hosts/tvpaint/plugins/create/create_workfile.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py index 265cef00ef..5caf20f27d 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_review.py +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -34,12 +34,12 @@ class TVPaintReviewCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() - existing_asset_name = None - if existing_instance is not None: - if AYON_SERVER_ENABLED: - existing_asset_name = existing_instance.get("folderPath") - if existing_asset_name is None: - existing_asset_name = existing_instance.get("asset") + if existing_instance is None: + existing_asset_name = None + elif AYON_SERVER_ENABLED: + existing_asset_name = existing_instance["folderPath"] + else: + existing_asset_name = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py index eec0f8483f..4ce5d7fc96 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_workfile.py +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -30,12 +30,12 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() - existing_asset_name = None - if existing_instance is not None: - if AYON_SERVER_ENABLED: - existing_asset_name = existing_instance.get("folderPath") - if existing_asset_name is None: - existing_asset_name = existing_instance.get("asset") + if existing_instance is None: + existing_asset_name = None + elif AYON_SERVER_ENABLED: + existing_asset_name = existing_instance["folderPath"] + else: + existing_asset_name = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) From 8a9a1e66f3fe007367d7895fcbcb452a88c4a366 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 19:08:08 +0200 Subject: [PATCH 0860/1224] comparison of UILabelDef is comparing label value --- openpype/lib/attribute_definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index a71d6cc72a..b8faae8f4c 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -240,6 +240,11 @@ class UILabelDef(UIDef): def __init__(self, label): super(UILabelDef, self).__init__(label=label) + def __eq__(self, other): + if not super(UILabelDef, self).__eq__(other): + return False + return self.label == other.label: + # --------------------------------------- # Attribute defintioins should hold value From bbfff158b055b4fcaf520396bdea0d5544321d08 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 26 Oct 2023 19:08:25 +0200 Subject: [PATCH 0861/1224] it is possible to define key of label to differentiate --- openpype/lib/attribute_definitions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index b8faae8f4c..658a3fe581 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -237,8 +237,8 @@ class UISeparatorDef(UIDef): class UILabelDef(UIDef): type = "label" - def __init__(self, label): - super(UILabelDef, self).__init__(label=label) + def __init__(self, label, key=None): + super(UILabelDef, self).__init__(label=label, key=key) def __eq__(self, other): if not super(UILabelDef, self).__eq__(other): From 576ac94e3168568e1a6e5bc136bc338fac19a8f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Oct 2023 19:12:15 +0200 Subject: [PATCH 0862/1224] Remove semicolon --- openpype/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 658a3fe581..3dd284b8e4 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -243,7 +243,7 @@ class UILabelDef(UIDef): def __eq__(self, other): if not super(UILabelDef, self).__eq__(other): return False - return self.label == other.label: + return self.label == other.label # --------------------------------------- From 0b0cfb4116ed6067b0416bb34ecd8a3dd7e19805 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 27 Oct 2023 14:24:37 +0200 Subject: [PATCH 0863/1224] add slash at the beginning of path --- openpype/client/entities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/client/entities.py b/openpype/client/entities.py index d085f90028..cbaa943743 100644 --- a/openpype/client/entities.py +++ b/openpype/client/entities.py @@ -22,4 +22,4 @@ def get_asset_name_identifier(asset_doc): return asset_doc["name"] parents = list(asset_doc["data"]["parents"]) parents.append(asset_doc["name"]) - return "/".join(parents) + return "/" + "/".join(parents) From dd72d45ce7881b46c90c6972f27cc101b02f7696 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 27 Oct 2023 14:36:22 +0200 Subject: [PATCH 0864/1224] fix path in assets widget --- openpype/tools/publisher/widgets/assets_widget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index 5f74b79c99..32be514dd7 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -140,9 +140,11 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): for name in sorted(children_by_name.keys()): child = children_by_name[name] child_id = child["_id"] - child_path = name if parent_path: - child_path = "{}/{}".format(parent_path, child_path) + child_path = "{}/{}".format(parent_path, name) + else: + child_path = "/{}".format(name) + has_children = bool(assets_by_parent_id.get(child_id)) icon = get_asset_icon(child, has_children) From 6a0decab459db0b83b777289521584f2eaca02a2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 27 Oct 2023 21:09:29 +0800 Subject: [PATCH 0865/1224] make sure to check invalid properties --- .../plugins/publish/validate_attributes.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index e98e73de06..2d3f09f972 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -29,10 +29,20 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not attributes: return - invalid_attributes = [key for key, value in attributes.items() - if rt.Execute(attributes[key]) != value] + for wrap_object, property_name in attributes.items(): + invalid_properties = [key for key in property_name.keys() + if not rt.Execute( + f'isProperty {wrap_object} "{key}"')] + if invalid_properties: + cls.log.error( + "Unknown Property Values:{}".format(invalid_properties)) + return invalid_properties + # TODO: support multiple varaible types in maxscript + invalid_attributes = [key for key, value in property_name.items() + if rt.Execute("{}.{}".format( + wrap_object, property_name[key]))!=value] - return invalid_attributes + return invalid_attributes def process(self, context): if not self.is_active(context.data): @@ -57,6 +67,10 @@ class ValidateAttributes(OptionalPyblishPluginMixin, context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) - invalid_attribute_keys = cls.get_invalid(context) - for key in invalid_attribute_keys: - attributes[key] = rt.Execute(attributes[key]) + for wrap_object, property_name in attributes.items(): + invalid_attributes = [key for key, value in property_name.items() + if rt.Execute("{}.{}".format( + wrap_object, property_name[key]))!=value] + for attrs in invalid_attributes: + rt.Execute("{}.{}={}".format( + wrap_object, attrs, attributes[wrap_object][attrs])) From 4f658d2f51cd3357a3d31ba7d607354debabaa19 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Fri, 27 Oct 2023 16:05:01 +0200 Subject: [PATCH 0866/1224] update blender creator --- openpype/hosts/blender/api/plugin.py | 14 ++++++++++++-- .../blender/plugins/create/create_blendScene.py | 2 +- .../hosts/blender/plugins/create/create_model.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 73d8fc0ed5..dbd9f25d68 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -9,6 +9,7 @@ from openpype.pipeline import ( Creator, CreatedInstance, LoaderPlugin, + get_current_task_name, ) from .pipeline import ( AVALON_CONTAINERS, @@ -235,11 +236,20 @@ class BlenderCreator(Creator): collection = bpy.data.collections.new(name=subset_name) bpy.context.scene.collection.children.link(collection) - collection["instance_node"] = instance_node = { + collection[AVALON_PROPERTY] = instance_node = { "name": collection.name, } - instance_data["instance_node"] = instance_node + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "label": subset_name, + "task": get_current_task_name(), + "subset": subset_name, + "instance_node": instance_node, + } + ) instance = CreatedInstance( self.family, subset_name, instance_data, self diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index ee8e52d3c5..23ff991654 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -21,7 +21,7 @@ class CreateBlendScene(plugin.Creator): def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): - """ Run the creator on Blender main thread""" + """Run the creator on Blender main thread.""" mti = ops.MainThreadItem( self._process, subset_name, instance_data, pre_create_data ) diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 3c8e9c4900..761d9fca9f 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -22,7 +22,7 @@ class CreateModel(plugin.BlenderCreator): def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): - """ Run the creator on Blender main thread""" + """Run the creator on Blender main thread.""" self._add_instance_to_context( CreatedInstance(self.family, subset_name, instance_data, self) ) From ec5bc71eb3529ba096e00d5261dfdf0608a53280 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 27 Oct 2023 16:42:56 +0200 Subject: [PATCH 0867/1224] skip kitsu module when creating ayon addons --- server_addon/create_ayon_addons.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 61dbd5c8d9..86139a65f8 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -205,7 +205,8 @@ def create_openpype_package( "shotgrid", "sync_server", "example_addons", - "slack" + "slack", + "kitsu", ] # Subdirs that won't be added to output zip file ignored_subpaths = [ From 3f8b250510e1cb0e6f8edae6afde87e5d2718c23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 27 Oct 2023 17:05:00 +0200 Subject: [PATCH 0868/1224] removed unncessary path filtering --- openpype/client/server/entities.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index fcb5ec2383..9e86dfdd63 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -241,21 +241,9 @@ def get_assets( if not new_asset_names: return - folders_by_name = collections.defaultdict(list) for folder in _folders_query( project_name, con, fields, folder_names=new_asset_names, **kwargs ): - folders_by_name[folder["name"]].append(folder) - - for name, folders in folders_by_name.items(): - folder = next( - ( - folder - for folder in folders - if folder["path"] == name - ), - folders[0] - ) yield convert_v4_folder_to_v3(folder, project_name) From 4bde7b7fd94ab4ca183f90d83ef347e738d8b843 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 27 Oct 2023 18:01:04 +0200 Subject: [PATCH 0869/1224] hiero: adding folderPath to creator - some minor typos fixes - modules sorting --- openpype/hosts/hiero/api/plugin.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 0e0632e032..dc90012b0f 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -31,7 +31,7 @@ def load_stylesheet(): class CreatorWidget(QtWidgets.QDialog): # output items - items = dict() + items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -642,8 +642,8 @@ class PublishClip: Returns: hiero.core.TrackItem: hiero track item object with pype tag """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + tag_data = {} types = { "shot": "shot", "folder": "folder", @@ -705,9 +705,10 @@ class PublishClip: self._create_parents() def convert(self): - # solve track item data and add them to tag data - self._convert_to_tag_data() + tag_hierarchy_data = self._convert_to_tag_data() + + self.tag_data.update(tag_hierarchy_data) # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation @@ -721,16 +722,28 @@ class PublishClip: if self.rename: # rename track item self.track_item.setName(new_name) - self.tag_data["asset"] = new_name + self.tag_data["asset_name"] = new_name else: - self.tag_data["asset"] = self.ti_name + self.tag_data["asset_name"] = self.ti_name self.tag_data["hierarchyData"]["shot"] = self.ti_name + # AYON unique identifier + folder_path = "/{}/{}".format( + tag_hierarchy_data["hierarchy"], + self.tag_data["asset_name"] + ) + self.tag_data["folderPath"] = folder_path + + # TODO: remove debug print + log.debug("___ folder_path: {}".format( + folder_path)) + if self.tag_data["heroTrack"] and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) else: self.tag_data.update({"reviewTrack": None}) + # TODO: remove debug print log.debug("___ self.tag_data: {}".format( pformat(self.tag_data) )) @@ -889,7 +902,7 @@ class PublishClip: tag_hierarchy_data = hero_data # add data to return data dict - self.tag_data.update(tag_hierarchy_data) + return tag_hierarchy_data def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ From 4e9173fa71b9e6b4c19994356ce704a7d3bd29f5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 27 Oct 2023 18:02:11 +0200 Subject: [PATCH 0870/1224] Hiero: adding asset_name and processing folderPath - refactor labels --- .../plugins/publish/precollect_instances.py | 60 +++++++++++++------ .../plugins/publish/precollect_workfile.py | 15 +++-- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 65b8fed49c..1acbbb3d88 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -1,9 +1,12 @@ import pyblish + +from openpype import AYON_SERVER_ENABLED from openpype.pipeline.editorial import is_overlapping_otio_ranges + from openpype.hosts.hiero import api as phiero from openpype.hosts.hiero.api.otio import hiero_export -import hiero +import hiero # # developer reload modules from pprint import pformat @@ -80,25 +83,24 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if k not in ("id", "applieswhole", "label") }) - asset = tag_data["asset"] + asset, asset_name = self._get_asset_data(tag_data) + subset = tag_data["subset"] # insert family into families - family = tag_data["family"] families = [str(f) for f in tag_data["families"]] - families.insert(0, str(family)) # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({})".format(clip_name) label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") data.update({ "name": "{}_{}".format(asset, subset), "label": label, "asset": asset, + "asset_name": asset_name, "item": track_item, "families": families, "publish": tag_data["publish"], @@ -176,6 +178,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): }) def create_shot_instance(self, context, **data): + subset = "shotMain" master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") item = data.get("item") @@ -188,23 +191,21 @@ class PrecollectInstances(pyblish.api.ContextPlugin): return asset = data["asset"] - subset = "shotMain" + asset_name = data["asset_name"] # insert family into families family = "shot" # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) - label += " [{}]".format(family) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, - "asset": asset, "family": family, "families": [] }) @@ -214,7 +215,34 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) + def _get_asset_data(self, data): + folder_path = ( + data.pop("folderPath") if data.get("folderPath") else None) + + if data.get("asset_name"): + asset_name = data["asset_name"] + else: + asset_name = data["asset"] + + # backward compatibility for clip tags + # which are missing folderPath key + # TODO remove this in future versions + if not folder_path: + hierarchy_path = data["hierarchy"] + folder_path = "/{}/{}".format( + hierarchy_path, + asset_name + ) + + if AYON_SERVER_ENABLED: + asset = folder_path + else: + asset = asset_name + + return asset, asset_name + def create_audio_instance(self, context, **data): + subset = "audioMain" master_layer = data.get("heroTrack") if not master_layer: @@ -229,23 +257,21 @@ class PrecollectInstances(pyblish.api.ContextPlugin): return asset = data["asset"] - subset = "audioMain" + asset_name = data["asset_name"] # insert family into families family = "audio" # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) - label += " [{}]".format(family) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, - "asset": asset, "family": family, "families": ["clip"] }) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 1d6bdc0257..8abb0885c6 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -18,7 +18,8 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.491 def process(self, context): - asset_name = context.data["asset"] + asset = context.data["asset"] + asset_name = asset if AYON_SERVER_ENABLED: asset_name = asset_name.split("/")[-1] @@ -29,7 +30,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): # adding otio timeline to context otio_timeline = hiero_export.create_otio_timeline() - # get workfile thumnail paths + # get workfile thumbnail paths tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") thumbnail_name = "workfile_thumbnail.png" thumbnail_path = os.path.join(tmp_staging, thumbnail_name) @@ -51,8 +52,8 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): } # get workfile paths - curent_file = project.path() - staging_dir, base_name = os.path.split(curent_file) + current_file = project.path() + staging_dir, base_name = os.path.split(current_file) # creating workfile representation workfile_representation = { @@ -63,10 +64,12 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): } family = "workfile" instance_data = { + "label": "{} - {}Main".format( + asset, family), "name": "{}_{}".format(asset_name, family), "asset": context.data["asset"], # TODO use 'get_subset_name' - "subset": "{}{}".format(asset_name, family.capitalize()), + "subset": "{}{}Main".format(asset_name, family.capitalize()), "item": project, "family": family, "families": [], @@ -81,7 +84,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): "activeProject": project, "activeTimeline": active_timeline, "otioTimeline": otio_timeline, - "currentFile": curent_file, + "currentFile": current_file, "colorspace": self.get_colorspace(project), "fps": fps } From efef9e2fd35abc0e8fe7989776bbaeeef87b8336 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 27 Oct 2023 18:06:54 +0200 Subject: [PATCH 0871/1224] small fix in timers manager --- openpype/modules/timers_manager/timers_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py index 43286f7da4..674d834a1d 100644 --- a/openpype/modules/timers_manager/timers_manager.py +++ b/openpype/modules/timers_manager/timers_manager.py @@ -247,7 +247,7 @@ class TimersManager( return { "project_name": project_name, "asset_id": str(asset_doc["_id"]), - "asset_name": asset_doc["name"], + "asset_name": asset_name, "task_name": task_name, "task_type": task_type, "hierarchy": hierarchy_items From d9a35e7804c5b747d2316d8b6a91b8ee0866809e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 27 Oct 2023 22:51:08 +0200 Subject: [PATCH 0872/1224] fixing extract hierarchy to ayon --- .../publish/extract_hierarchy_to_ayon.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index fe8cb40ad2..ef69369d67 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -191,15 +191,15 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): """ # filter only the active publishing instances - active_folder_names = set() + active_folder_paths = set() for instance in context: if instance.data.get("publish") is not False: - active_folder_names.add(instance.data.get("asset")) + active_folder_paths.add(instance.data.get("asset")) - active_folder_names.discard(None) + active_folder_paths.discard(None) - self.log.debug("Active folder names: {}".format(active_folder_names)) - if not active_folder_names: + self.log.debug("Active folder paths: {}".format(active_folder_paths)) + if not active_folder_paths: return None project_item = None @@ -230,12 +230,13 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): if not children_context: continue - for asset_name, asset_info in children_context.items(): + for asset, asset_info in children_context.items(): if ( - asset_name not in active_folder_names + asset not in active_folder_paths and not asset_info.get("childs") ): continue + asset_name = asset.split("/")[-1] item_id = uuid.uuid4().hex new_item = copy.deepcopy(asset_info) new_item["name"] = asset_name @@ -252,7 +253,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): items_by_id[item_id] = new_item parent_id_by_item_id[item_id] = parent_id - if asset_name in active_folder_names: + if asset in active_folder_paths: valid_ids.add(item_id) hierarchy_queue.append((item_id, new_children_context)) From dcf5855a477f35fd9fc160c8830974ec7cd2b8ed Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 27 Oct 2023 22:51:24 +0200 Subject: [PATCH 0873/1224] hound --- openpype/hosts/hiero/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index dc90012b0f..f72d27fed5 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -902,7 +902,7 @@ class PublishClip: tag_hierarchy_data = hero_data # add data to return data dict - return tag_hierarchy_data + return tag_hierarchy_data def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ From 7a156e8499c4be6e7e505fc9a9641008c7e2f829 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 28 Oct 2023 03:24:38 +0000 Subject: [PATCH 0874/1224] [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 4c58a5098f..d6839c9b70 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.4" +__version__ = "3.17.5-nightly.1" From 0443fa0a290b84413be0bd15a10921789d06a68f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Oct 2023 03:25:22 +0000 Subject: [PATCH 0875/1224] 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 b92061f39a..73505368dd 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.5-nightly.1 - 3.17.4 - 3.17.4-nightly.2 - 3.17.4-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.3 - 3.15.1-nightly.2 - 3.15.1-nightly.1 - - 3.15.0 validations: required: true - type: dropdown From fe4e38190feaea158e077c8075330c69e9f10b4b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 10:00:37 +0100 Subject: [PATCH 0876/1224] skip 'kitsu' addon in openpype modules load --- openpype/modules/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 080be251f3..e8b85d0e93 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -66,6 +66,7 @@ IGNORED_FILENAMES_IN_AYON = { "shotgrid", "sync_server", "slack", + "kitsu", } From c029fa632489529e36dbdaec5b74d2e938f46847 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 30 Oct 2023 17:21:29 +0800 Subject: [PATCH 0877/1224] support invalid checks on different variable types of attributes in Maxscript --- .../max/plugins/publish/validate_attributes.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 2d3f09f972..fa9912de07 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -38,9 +38,20 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "Unknown Property Values:{}".format(invalid_properties)) return invalid_properties # TODO: support multiple varaible types in maxscript - invalid_attributes = [key for key, value in property_name.items() - if rt.Execute("{}.{}".format( - wrap_object, property_name[key]))!=value] + invalid_attributes = [] + for key, value in property_name.items(): + property_key = rt.Execute("{}.{}".format( + wrap_object, key)) + if isinstance(value, str) and "#" not in value: + if property_key != '"{}"'.format(value): + invalid_attributes.append(key) + + elif isinstance(value, bool): + if property_key != value: + invalid_attributes.append(key) + else: + if property_key != '{}'.format(value): + invalid_attributes.append(key) return invalid_attributes @@ -71,6 +82,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, invalid_attributes = [key for key, value in property_name.items() if rt.Execute("{}.{}".format( wrap_object, property_name[key]))!=value] + for attrs in invalid_attributes: rt.Execute("{}.{}={}".format( wrap_object, attrs, attributes[wrap_object][attrs])) From 0550668d3d3356a91f6b9c0fc47d6ebc27000ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 30 Oct 2023 10:24:01 +0100 Subject: [PATCH 0878/1224] Update openpype/hosts/hiero/api/plugin.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/hiero/api/plugin.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index f72d27fed5..b0c73e41fb 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -733,11 +733,6 @@ class PublishClip: self.tag_data["asset_name"] ) self.tag_data["folderPath"] = folder_path - - # TODO: remove debug print - log.debug("___ folder_path: {}".format( - folder_path)) - if self.tag_data["heroTrack"] and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) else: From 05ab5e2e0fe53f4d585db7d4050ab6f6051b4f7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 12:14:55 +0100 Subject: [PATCH 0879/1224] use 'asset' from context instead of from anatomy data --- .../plugins/publish/create_publish_royalrender_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 3eb49a39ee..e13bf97e54 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -189,7 +189,7 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, environment = RREnvList({ "AVALON_PROJECT": anatomy_data["project"]["name"], - "AVALON_ASSET": anatomy_data["asset"], + "AVALON_ASSET": instance.context.data["asset"], "AVALON_TASK": anatomy_data["task"]["name"], "OPENPYPE_USERNAME": anatomy_data["user"] }) From f27a25d2440671b19293bfba0c84180657178753 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 12:15:27 +0100 Subject: [PATCH 0880/1224] add 'folder' key to template data in harmony collectors --- .../plugins/publish/collect_harmony_scenes.py | 3 +++ .../plugins/publish/collect_harmony_zips.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py index 48c36aa067..c435ca2096 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py @@ -60,6 +60,9 @@ class CollectHarmonyScenes(pyblish.api.InstancePlugin): # updating hierarchy data anatomy_data_new.update({ "asset": asset_data["name"], + "folder": { + "name": asset_data["name"], + }, "task": { "name": task, "type": task_type, diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py index 40a969f8df..d90215e767 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py @@ -56,6 +56,9 @@ class CollectHarmonyZips(pyblish.api.InstancePlugin): anatomy_data_new.update( { "asset": asset_data["name"], + "folder": { + "name": asset_data["name"], + }, "task": { "name": task, "type": task_type, From 4c55f515dd8513382fc8b9b7ac7710716708f6c6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 12:16:07 +0100 Subject: [PATCH 0881/1224] do not use 'instance.data["asset"]' to prepare template data --- .../publish/collect_anatomy_instance_data.py | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index cc6da9b2c3..1b4b44e40e 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -187,35 +187,29 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Storing anatomy data to instance data.") project_doc = context.data["projectEntity"] - context_asset_doc = context.data.get("assetEntity") - project_task_types = project_doc["config"]["tasks"] for instance in context: + asset_doc = instance.data.get("assetEntity") anatomy_updates = { - "asset": instance.data["asset"], - "folder": { - "name": instance.data["asset"], - }, "family": instance.data["family"], "subset": instance.data["subset"], } - - # Hierarchy - asset_doc = instance.data.get("assetEntity") - if ( - asset_doc - and ( - not context_asset_doc - or asset_doc["_id"] != context_asset_doc["_id"] - ) - ): + if asset_doc: parents = asset_doc["data"].get("parents") or list() parent_name = project_doc["name"] if parents: parent_name = parents[-1] - anatomy_updates["hierarchy"] = "/".join(parents) - anatomy_updates["parent"] = parent_name + + hierarchy = "/".join(parents) + anatomy_updates.update({ + "asset": asset_doc["name"], + "hierarchy": hierarchy, + "parent": parent_name, + "folder": { + "name": asset_doc["name"], + }, + }) # Task task_type = None From 6c7e5c66a6b85e2ee8e42c2c9b38ea6639d9dd42 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 30 Oct 2023 21:13:42 +0800 Subject: [PATCH 0882/1224] support invalid checks on different variable types of attributes in Maxscript & repair actions --- .../plugins/publish/validate_attributes.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index fa9912de07..f266b2bca1 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -42,16 +42,16 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for key, value in property_name.items(): property_key = rt.Execute("{}.{}".format( wrap_object, key)) - if isinstance(value, str) and "#" not in value: - if property_key != '"{}"'.format(value): - invalid_attributes.append(key) - - elif isinstance(value, bool): - if property_key != value: - invalid_attributes.append(key) + if isinstance(value, str) and ( + value.startswith("#") and not value.endswith(")") + ): + # not applicable for #() array value type + # and only applicable for enum i.e. #bob, #sally + if "#{}".format(property_key) != value: + invalid_attributes.append((wrap_object, key)) else: - if property_key != '{}'.format(value): - invalid_attributes.append(key) + if property_key != value: + invalid_attributes.append((wrap_object, key)) return invalid_attributes @@ -62,12 +62,14 @@ class ValidateAttributes(OptionalPyblishPluginMixin, invalid_attributes = self.get_invalid(context) if invalid_attributes: bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid) for invalid in invalid_attributes + "- {}".format(invalid) for invalid + in invalid_attributes ) report = ( "Required Attribute(s) have invalid value(s).\n\n" f"{bullet_point_invalid_statement}\n\n" - "You can use repair action to fix it." + "You can use repair action to fix them if they are not\n" + "unknown property value(s)" ) raise PublishValidationError( report, title="Invalid Value(s) for Required Attribute(s)") @@ -78,11 +80,16 @@ class ValidateAttributes(OptionalPyblishPluginMixin, context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) - for wrap_object, property_name in attributes.items(): - invalid_attributes = [key for key, value in property_name.items() - if rt.Execute("{}.{}".format( - wrap_object, property_name[key]))!=value] - - for attrs in invalid_attributes: - rt.Execute("{}.{}={}".format( - wrap_object, attrs, attributes[wrap_object][attrs])) + invalid_attributes = cls.get_invalid(context) + for attrs in invalid_attributes: + prop, attr = attrs + value = attributes[prop][attr] + if isinstance(value, str) and not value.startswith("#"): + attribute_fix = '{}.{}="{}"'.format( + prop, attr, value + ) + else: + attribute_fix = "{}.{}={}".format( + prop, attr, value + ) + rt.Execute(attribute_fix) From 319a236bb2bfaf59dcaa2568685fb30bc1a04e8c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 16:07:14 +0100 Subject: [PATCH 0883/1224] do not strip asset name --- openpype/tools/creator/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index 47f27a262a..117519e1d7 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -214,7 +214,7 @@ class CreatorWindow(QtWidgets.QDialog): asset_name = self._asset_name_input.text() # Early exit if no asset name - if not asset_name.strip(): + if not asset_name: self._build_menu() self.echo("Asset name is required ..") self._set_valid_state(False) From 027cced5f58bb6af9b476f96744bf0909c5b6ebc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 30 Oct 2023 16:40:46 +0100 Subject: [PATCH 0884/1224] show full folder path in look assigner --- openpype/hosts/maya/tools/mayalookassigner/commands.py | 8 +++++--- openpype/hosts/maya/tools/mayalookassigner/widgets.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py index 5cc4f84931..86df502ecd 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/commands.py +++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py @@ -4,7 +4,7 @@ from collections import defaultdict import maya.cmds as cmds -from openpype.client import get_assets +from openpype.client import get_assets, get_asset_name_identifier from openpype.pipeline import ( remove_container, registered_host, @@ -128,7 +128,8 @@ def create_items_from_nodes(nodes): project_name = get_current_project_name() asset_ids = set(id_hashes.keys()) - asset_docs = get_assets(project_name, asset_ids, fields=["name"]) + fields = {"_id", "name", "data.parents"} + asset_docs = get_assets(project_name, asset_ids, fields=fields) asset_docs_by_id = { str(asset_doc["_id"]): asset_doc for asset_doc in asset_docs @@ -156,8 +157,9 @@ def create_items_from_nodes(nodes): namespace = get_namespace_from_node(node) namespaces.add(namespace) + label = get_asset_name_identifier(asset_doc) asset_view_items.append({ - "label": asset_doc["name"], + "label": label, "asset": asset_doc, "looks": looks, "namespaces": namespaces diff --git a/openpype/hosts/maya/tools/mayalookassigner/widgets.py b/openpype/hosts/maya/tools/mayalookassigner/widgets.py index 82c37e2104..ef29a4c726 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/widgets.py +++ b/openpype/hosts/maya/tools/mayalookassigner/widgets.py @@ -3,6 +3,7 @@ from collections import defaultdict from qtpy import QtWidgets, QtCore +from openpype.client import get_asset_name_identifier from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, @@ -126,7 +127,7 @@ class AssetOutliner(QtWidgets.QWidget): asset_namespaces = defaultdict(set) for item in items: asset_id = str(item["asset"]["_id"]) - asset_name = item["asset"]["name"] + asset_name = get_asset_name_identifier(item["asset"]) asset_namespaces[asset_name].add(item.get("namespace")) if asset_name in assets: From 32e88ebc9663431dcc58a62c8e4a4bb784848966 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 30 Oct 2023 23:47:53 +0100 Subject: [PATCH 0885/1224] Show workfiles tool if current file is not set to make it clear there's user interaction needed to save --- openpype/hosts/resolve/api/menu.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index 160cc44fdb..9c6fe4957c 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -118,6 +118,9 @@ class OpenPypeMenu(QtWidgets.QWidget): host = registered_host() current_file = host.get_current_workfile() if not current_file: + print("Current project is not saved. " + "Please save once first via workfiles tool.") + host_tools.show_workfiles() return print(f"Saving current file to: {current_file}") From f0b8d8d79826df7c27650dfbe68046e2d2d63d9d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:19:37 +0800 Subject: [PATCH 0886/1224] add docstrings and clean up the codes on the validator --- .../plugins/publish/validate_attributes.py | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index f266b2bca1..f603934eed 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -10,9 +10,47 @@ from openpype.pipeline.publish import ( ) +def has_property(object_name, property_name): + """Return whether an object has a property with given name""" + return rt.Execute(f'isProperty {object_name} "{property_name}"') + +def is_matching_value(object_name, property_name, value): + """Return whether an existing property matches value `value""" + property_value = rt.Execute(f"{object_name}.{property_name}") + + # Wrap property value if value is a string valued attributes + # starting with a `#` + if ( + isinstance(value, str) and + value.startswith("#") and + not value.endswith(")") + ): + # prefix value with `#` + # not applicable for #() array value type + # and only applicable for enum i.e. #bob, #sally + property_value = f"#{property_value}" + + return property_value == value + + class ValidateAttributes(OptionalPyblishPluginMixin, ContextPlugin): - """Validates attributes are consistent in 3ds max.""" + """Validates attributes in the project setting are consistent + with the nodes from MaxWrapper Class in 3ds max. + E.g. "renderers.current.separateAovFiles", + "renderers.production.PrimaryGIEngine" + Admin(s) need to put json below and enable this validator for a check: + { + "renderers.current":{ + "separateAovFiles" : True + } + "renderers.production":{ + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE", + } + .... + } + + """ order = ValidatorOrder hosts = ["max"] @@ -28,32 +66,24 @@ class ValidateAttributes(OptionalPyblishPluginMixin, ) if not attributes: return + invalid = [] + for object_name, required_properties in attributes.items(): + if not rt.Execute(f"isValidValue {object_name}"): + # Skip checking if the node does not + # exist in MaxWrapper Class + continue - for wrap_object, property_name in attributes.items(): - invalid_properties = [key for key in property_name.keys() - if not rt.Execute( - f'isProperty {wrap_object} "{key}"')] - if invalid_properties: - cls.log.error( - "Unknown Property Values:{}".format(invalid_properties)) - return invalid_properties - # TODO: support multiple varaible types in maxscript - invalid_attributes = [] - for key, value in property_name.items(): - property_key = rt.Execute("{}.{}".format( - wrap_object, key)) - if isinstance(value, str) and ( - value.startswith("#") and not value.endswith(")") - ): - # not applicable for #() array value type - # and only applicable for enum i.e. #bob, #sally - if "#{}".format(property_key) != value: - invalid_attributes.append((wrap_object, key)) - else: - if property_key != value: - invalid_attributes.append((wrap_object, key)) + for property_name, value in required_properties.items(): + if not has_property(object_name, property_name): + cls.log.error(f"Non-existing property: {object_name}.{property_name}") + invalid.append((object_name, property_name)) - return invalid_attributes + if not is_matching_value(object_name, property_name, value): + cls.log.error( + f"Invalid value for: {object_name}.{property_name}. Should be: {value}") + invalid.append((object_name, property_name)) + + return invalid def process(self, context): if not self.is_active(context.data): From 4c204a87a917ef05bf5b23e3f180d4f95650a3fb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:20:25 +0800 Subject: [PATCH 0887/1224] hound --- openpype/hosts/max/plugins/publish/validate_attributes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index f603934eed..44d6c64139 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -14,6 +14,7 @@ def has_property(object_name, property_name): """Return whether an object has a property with given name""" return rt.Execute(f'isProperty {object_name} "{property_name}"') + def is_matching_value(object_name, property_name, value): """Return whether an existing property matches value `value""" property_value = rt.Execute(f"{object_name}.{property_name}") From 33a21674c5e752c19be8636ab6587f38f91f8f59 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 14:21:37 +0800 Subject: [PATCH 0888/1224] hound --- openpype/hosts/max/plugins/publish/validate_attributes.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 44d6c64139..5697237c95 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -76,12 +76,14 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for property_name, value in required_properties.items(): if not has_property(object_name, property_name): - cls.log.error(f"Non-existing property: {object_name}.{property_name}") + cls.log.error( + f"Non-existing property: {object_name}.{property_name}") invalid.append((object_name, property_name)) if not is_matching_value(object_name, property_name, value): cls.log.error( - f"Invalid value for: {object_name}.{property_name}. Should be: {value}") + f"Invalid value for: {object_name}.{property_name}" + f". Should be: {value}") invalid.append((object_name, property_name)) return invalid From ce80ca2397c7ed9b609f0358a9c0595289a8e260 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 16:10:22 +0800 Subject: [PATCH 0889/1224] debug message --- openpype/hosts/max/plugins/publish/validate_attributes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 5697237c95..00b9d34c06 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -72,6 +72,8 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not rt.Execute(f"isValidValue {object_name}"): # Skip checking if the node does not # exist in MaxWrapper Class + cls.log.debug(f"Unable to find '{object_name}'." + f" Skipping validation of attributes") continue for property_name, value in required_properties.items(): From 3218b8064cdd00f7efab87bab935e1c8cb130c16 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 16:11:57 +0800 Subject: [PATCH 0890/1224] hound & docstring tweak --- openpype/hosts/max/plugins/publish/validate_attributes.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 00b9d34c06..75d3f05d07 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -46,7 +46,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "separateAovFiles" : True } "renderers.production":{ - "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE", + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" } .... } @@ -79,7 +79,8 @@ class ValidateAttributes(OptionalPyblishPluginMixin, for property_name, value in required_properties.items(): if not has_property(object_name, property_name): cls.log.error( - f"Non-existing property: {object_name}.{property_name}") + "Non-existing property: " + f"{object_name}.{property_name}") invalid.append((object_name, property_name)) if not is_matching_value(object_name, property_name, value): From 3606f312b0f671404cea584dfdca2d9af749e18c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 16:33:06 +0800 Subject: [PATCH 0891/1224] fix the wrong aspect ratio and viewport doesn't maximize to 1 during context --- openpype/hosts/max/api/preview_animation.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 1bf99b86d0..bef5741343 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -23,8 +23,8 @@ def play_preview_when_done(has_autoplay): @contextlib.contextmanager -def viewport_camera(camera): - """Set viewport camera during context +def viewport_layout_and_camera(camera): + """Set viewport layout and camera during context ***For 3dsMax 2024+ Args: camera (str): viewport camera @@ -36,9 +36,12 @@ def viewport_camera(camera): original = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) try: + if rt.viewport.getLayout() != rt.Name("layout_1"): + rt.viewport.setLayout(rt.Name("layout_1")) rt.viewport.setCamera(review_camera) yield finally: + rt.viewport.ResetAllViews() rt.viewport.setCamera(original) @@ -162,6 +165,7 @@ def _render_preview_animation_max_pre_2024( Returns: list: Created filepaths """ + # get the screenshot percent = percentSize / 100.0 res_width = int(round(rt.renderWidth * percent)) @@ -190,7 +194,7 @@ def _render_preview_animation_max_pre_2024( widthCrop = dib_height * renderRatio leftEdge = int((dib_width - widthCrop) / 2.0) tempImage_bmp = rt.bitmap(widthCrop, dib_height) - src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height) + src_box_value = rt.Box2(0, leftEdge, widthCrop, dib_height) rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) # copy the bitmap and close it rt.copy(tempImage_bmp, preview_res) @@ -243,7 +247,7 @@ def render_preview_animation( if viewport_options is None: viewport_options = viewport_options_for_preview_animation() with play_preview_when_done(False): - with viewport_camera(camera): + with viewport_layout_and_camera(camera): with render_resolution(width, height): if int(get_max_version()) < 2024: with viewport_preference_setting( From e120b55fa47d7b716aa71122f4ecc3aa93ff6a06 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:13:24 +0800 Subject: [PATCH 0892/1224] setLayout in regards to original layout --- openpype/hosts/max/api/preview_animation.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index bef5741343..22de298175 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -29,11 +29,12 @@ def viewport_layout_and_camera(camera): Args: camera (str): viewport camera """ - original = rt.viewport.getCamera() - if not original: + original_camera = rt.viewport.getCamera() + original_layout = rt.viewport.getLayout() + if not original_camera: # if there is no original camera # use the current camera as original - original = rt.getNodeByName(camera) + original_camera = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) try: if rt.viewport.getLayout() != rt.Name("layout_1"): @@ -41,8 +42,8 @@ def viewport_layout_and_camera(camera): rt.viewport.setCamera(review_camera) yield finally: - rt.viewport.ResetAllViews() - rt.viewport.setCamera(original) + rt.viewport.setLayout(original_layout) + rt.viewport.setCamera(original_camera) @contextlib.contextmanager From 009cda005227158561d898aa59c28ca16ae4166c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:18:06 +0800 Subject: [PATCH 0893/1224] update the debug message with dots --- openpype/hosts/max/plugins/publish/validate_attributes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 75d3f05d07..172b65e955 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -73,7 +73,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, # Skip checking if the node does not # exist in MaxWrapper Class cls.log.debug(f"Unable to find '{object_name}'." - f" Skipping validation of attributes") + " Skipping validation of attributes.") continue for property_name, value in required_properties.items(): @@ -86,7 +86,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not is_matching_value(object_name, property_name, value): cls.log.error( f"Invalid value for: {object_name}.{property_name}" - f". Should be: {value}") + f" Should be: {value}") invalid.append((object_name, property_name)) return invalid @@ -105,7 +105,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, "Required Attribute(s) have invalid value(s).\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to fix them if they are not\n" - "unknown property value(s)" + "unknown property value(s)." ) raise PublishValidationError( report, title="Invalid Value(s) for Required Attribute(s)") From dadd258cf1f1938ef609ecec689d08bb19815198 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:26:25 +0800 Subject: [PATCH 0894/1224] make the viewport_layout_and_camera reuseable --- openpype/hosts/max/api/preview_animation.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 22de298175..dcf243d31e 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -23,11 +23,13 @@ def play_preview_when_done(has_autoplay): @contextlib.contextmanager -def viewport_layout_and_camera(camera): +def viewport_layout_and_camera(camera, layout="layout_1"): """Set viewport layout and camera during context ***For 3dsMax 2024+ Args: camera (str): viewport camera + layout (str): layout to use in viewport, defaults to `layout_1` + Use None to not change viewport layout during context. """ original_camera = rt.viewport.getCamera() original_layout = rt.viewport.getLayout() @@ -37,8 +39,10 @@ def viewport_layout_and_camera(camera): original_camera = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) try: - if rt.viewport.getLayout() != rt.Name("layout_1"): - rt.viewport.setLayout(rt.Name("layout_1")) + if layout is not None: + layout = rt.Name(layout) + if rt.viewport.getLayout() != layout: + rt.viewport.setLayout(layout) rt.viewport.setCamera(review_camera) yield finally: From ad0b941475c67196ad7e09beada2bd81b2d51a63 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 17:28:42 +0800 Subject: [PATCH 0895/1224] lowercase invalid msg for the condition of not is_maching_value function --- openpype/hosts/max/plugins/publish/validate_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 172b65e955..0cd405aebd 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -86,7 +86,7 @@ class ValidateAttributes(OptionalPyblishPluginMixin, if not is_matching_value(object_name, property_name, value): cls.log.error( f"Invalid value for: {object_name}.{property_name}" - f" Should be: {value}") + f" should be: {value}") invalid.append((object_name, property_name)) return invalid From 6daa1b898a2cfe13509c71861816fad221778816 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 31 Oct 2023 10:47:23 +0000 Subject: [PATCH 0896/1224] Use collections as asset group for blendscene family --- .../plugins/create/create_blendScene.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 63bcf212ff..96e63924d3 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -31,21 +31,11 @@ class CreateBlendScene(plugin.Creator): asset = self.data["asset"] subset = self.data["subset"] name = plugin.asset_name(asset, subset) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) + + # Create the new asset group as collection + 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) - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - return asset_group From 1c45fa139b38ea8f2c9055613a5f536f2b9fa40e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 31 Oct 2023 10:48:05 +0000 Subject: [PATCH 0897/1224] Include collections asset group to get unique number --- openpype/hosts/blender/api/plugin.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index fb87d08cce..45b0c60b3b 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -9,7 +9,10 @@ from openpype.pipeline import ( LegacyCreator, LoaderPlugin, ) -from .pipeline import AVALON_CONTAINERS +from .pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, +) from .ops import ( MainThreadItem, execute_in_main_thread @@ -40,9 +43,16 @@ def get_unique_number( avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: return "01" - asset_groups = avalon_container.all_objects - - container_names = [c.name for c in asset_groups if c.type == 'EMPTY'] + # Check the names of both object and collection containers + obj_asset_groups = avalon_container.all_objects + obj_group_names = [ + c.name for c in obj_asset_groups + if c.type == 'EMPTY' and c.get(AVALON_PROPERTY)] + coll_asset_groups = avalon_container.children_recursive + coll_group_names = [ + c.name for c in coll_asset_groups + if c.get(AVALON_PROPERTY)] + container_names = obj_group_names + coll_group_names count = 1 name = f"{asset}_{count:0>2}_{subset}" while name in container_names: From a180a276a3838dd9b433e03c7e52b46bd39d5886 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 31 Oct 2023 10:49:17 +0000 Subject: [PATCH 0898/1224] Add check that instance is object to pack images --- .../hosts/blender/plugins/publish/extract_blend.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index c8eeef7fd7..4b6d9e7c69 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -25,11 +25,11 @@ class ExtractBlend(publish.Extractor): data_blocks = set() - for obj in instance: - data_blocks.add(obj) - # Pack used images in the blend files. - if obj.type == 'MESH': - for material_slot in obj.material_slots: + for data in instance: + data_blocks.add(data) + if isinstance(data, bpy.types.Object) and data.type == 'MESH': + # Pack used images in the blend files. + for material_slot in data.material_slots: mat = material_slot.material if mat and mat.use_nodes: tree = mat.node_tree From 371f9a52755a6c761b87ff1e6a07fe59384acbc1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 31 Oct 2023 11:49:17 +0100 Subject: [PATCH 0899/1224] Bugfix: Collect Rendered Files only collecting first instance (#5832) * Bugfix: Collect all instances from the metadata file - don't return on first iteration * Fix initial state of variable --- .../plugins/publish/collect_rendered_files.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 8a5a5a83f1..a249b3acda 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -56,6 +56,17 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): data_object["stagingDir"] = anatomy.fill_root(staging_dir) def _process_path(self, data, anatomy): + """Process data of a single JSON publish metadata file. + + Args: + data: The loaded metadata from the JSON file + anatomy: Anatomy for the current context + + Returns: + bool: Whether any instance of this particular metadata file + has a persistent staging dir. + + """ # validate basic necessary data data_err = "invalid json file - missing data" required = ["asset", "user", "comment", @@ -89,6 +100,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ["FTRACK_SERVER"] = ftrack["FTRACK_SERVER"] # now we can just add instances from json file and we are done + any_staging_dir_persistent = False for instance_data in data.get("instances"): self.log.debug(" - processing instance for {}".format( @@ -106,6 +118,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): staging_dir_persistent = instance.data.get( "stagingDir_persistent", False ) + if staging_dir_persistent: + any_staging_dir_persistent = True + representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) @@ -127,7 +142,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.debug( f"Adding audio to instance: {instance.data['audio']}") - return staging_dir_persistent + return any_staging_dir_persistent def process(self, context): self._context = context From fd30a0426cb2a10b93384e8a6ce898e57b7d5114 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 31 Oct 2023 10:49:58 +0000 Subject: [PATCH 0900/1224] Separate blendscene loader from blend loader --- .../hosts/blender/plugins/load/load_blend.py | 2 +- .../blender/plugins/load/load_blendscene.py | 215 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/blender/plugins/load/load_blendscene.py diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 25d6568889..0719c5c97d 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -20,7 +20,7 @@ from openpype.hosts.blender.api.pipeline import ( class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" - families = ["model", "rig", "layout", "camera", "blendScene"] + families = ["model", "rig", "layout", "camera"] representations = ["blend"] label = "Append Blend" diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py new file mode 100644 index 0000000000..8c43f40bdd --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -0,0 +1,215 @@ +from typing import Dict, List, Optional +from pathlib import Path + +import bpy + +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) +from openpype.pipeline.create import get_legacy_creator_by_name +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.lib import imprint +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, +) + + +class BlendSceneLoader(plugin.AssetLoader): + """Load assets from a .blend file.""" + + families = ["blendScene"] + representations = ["blend"] + + label = "Append Blend" + icon = "code-fork" + color = "orange" + + @staticmethod + def _get_asset_container(collections): + for coll in collections: + parents = [c for c in collections if c.user_of_id(coll)] + if coll.get(AVALON_PROPERTY) and not parents: + return coll + + return None + + def _process_data(self, libpath, group_name, family): + # Append all the data from the .blend file + with bpy.data.libraries.load( + libpath, link=False, relative=False + ) as (data_from, data_to): + for attr in dir(data_to): + setattr(data_to, attr, getattr(data_from, attr)) + + members = [] + + # Rename the object to add the asset name + for attr in dir(data_to): + for data in getattr(data_to, attr): + data.name = f"{group_name}:{data.name}" + members.append(data) + + container = self._get_asset_container( + data_to.collections) + assert container, "No asset group found" + + container.name = group_name + + # Link the group to the scene + bpy.context.scene.collection.children.link(container) + + # Remove the library from the blend file + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + + return container, members + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.filepath_from_context(context) + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + try: + family = context["representation"]["context"]["family"] + except ValueError: + family = "model" + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + container, members = self._process_data(libpath, group_name, family) + + avalon_container.children.link(container) + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name, + "members": members, + } + + container[AVALON_PROPERTY] = data + + objects = [ + obj for obj in bpy.data.objects + if obj.name.startswith(f"{group_name}:") + ] + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """ + Update the loaded asset. + """ + group_name = container["objectName"] + asset_group = bpy.data.collections.get(group_name) + libpath = Path(get_representation_path(representation)).as_posix() + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + + collection_parents = {} + members = asset_group.get(AVALON_PROPERTY).get("members", []) + loaded_collections = {c for c in bpy.data.collections if c in members} + loaded_collections.add(bpy.data.collections.get(AVALON_CONTAINERS)) + for member in members: + if isinstance(member, bpy.types.Object): + member_parents = set(member.users_collection) + elif isinstance(member, bpy.types.Collection): + member_parents = { + c for c in bpy.data.collections if c.user_of_id(member)} + else: + continue + + member_parents = member_parents.difference(loaded_collections) + if member_parents: + collection_parents[member.name] = list(member_parents) + + old_data = dict(asset_group.get(AVALON_PROPERTY)) + + self.exec_remove(container) + + family = container["family"] + asset_group, members = self._process_data(libpath, group_name, family) + + for member in members: + if member.name in collection_parents: + for parent in collection_parents[member.name]: + if isinstance(member, bpy.types.Object): + parent.objects.link(member) + elif isinstance(member, bpy.types.Collection): + parent.children.link(member) + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + avalon_container.children.link(asset_group) + + # Restore the old data, but reset memebers, as they don't exist anymore + # This avoids a crash, because the memory addresses of those members + # are not valid anymore + old_data["members"] = [] + asset_group[AVALON_PROPERTY] = old_data + + new_data = { + "libpath": libpath, + "representation": str(representation["_id"]), + "parent": str(representation["parent"]), + "members": members, + } + + imprint(asset_group, new_data) + + def exec_remove(self, container: Dict) -> bool: + """ + Remove an existing container from a Blender scene. + """ + group_name = container["objectName"] + asset_group = bpy.data.collections.get(group_name) + + attrs = [ + attr for attr in dir(bpy.data) + if isinstance( + getattr(bpy.data, attr), + bpy.types.bpy_prop_collection + ) + ] + + members = asset_group.get(AVALON_PROPERTY).get("members", []) + + for attr in attrs: + for data in getattr(bpy.data, attr): + if data in members: + # Skip the asset group + if data == asset_group: + continue + getattr(bpy.data, attr).remove(data) + + bpy.data.collections.remove(asset_group) From 6ec87aa06df1dbb7f9ae28fae286e61e73ba3355 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 31 Oct 2023 11:10:20 +0000 Subject: [PATCH 0901/1224] Hound fixes --- openpype/hosts/blender/plugins/load/load_blendscene.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index 8c43f40bdd..fe7afb3119 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -7,7 +7,6 @@ from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) -from openpype.pipeline.create import get_legacy_creator_by_name from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( From e39689ba8e1a7a9de754ecf5becb487d5fda7665 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:28:15 +0800 Subject: [PATCH 0902/1224] add docs --- website/docs/artist_hosts_3dsmax.md | 24 ++++++++++++++++++ .../assets/3dsmax_validate_attributes.png | Bin 0 -> 35154 bytes 2 files changed, 24 insertions(+) create mode 100644 website/docs/assets/3dsmax_validate_attributes.png diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index fffab8ca5d..bc79094746 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -118,4 +118,28 @@ Current OpenPype integration (ver 3.15.0) supports only ```PointCache```, ```Ca This part of documentation is still work in progress. ::: +## Validators + +Current Openpype integration supports different validators such as Frame Range and Attributes. +Some validators are mandatory while some are optional and user can choose to enable them in the setting. + +**Validate Frame Range**: Optional Validator for checking Frame Range + +**Validate Attributes**: Optional Validator for checking if object properties' attributes are valid + in MaxWrapper Class. +:::note + Users can write the properties' attributes they want to check in dict format in the setting + before validation. + E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` + User can put the attributes in the dict format below + ``` + { + "renderer.current":{ + "separateAovFiles" : True + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" + } + } + ``` + ![Validate Attribute Setting](assets/3dsmax_validate_attributes.png) +::: ## ...to be added diff --git a/website/docs/assets/3dsmax_validate_attributes.png b/website/docs/assets/3dsmax_validate_attributes.png new file mode 100644 index 0000000000000000000000000000000000000000..5af82361887f47fda96607886ec10e15eb355df1 GIT binary patch literal 35154 zcmagF30P8F^fzpE%gW3~txRpWjiy#kIcHw8a;ltCL@Ub)K~n(*r?Rv(^H$D^Lr$q7 zDGCZAmO0NODj=Hkh!dh9@S*qK_y2zH^S#f<^C+Bs_C9;{t0zL}Bu-V=L8 zL`1~z-udgHh{%p15s~fQyLSm&{^IAq6n<^i0rT4{M%ON`SqEw@rj$|BVP-Mo0sWx-zU$W_&yP~iHIQ2IC;2v-S>2Z z`1+jD1R&4&ozc8{<&3}Zf8dp^D-Dh7|DV@t*VX>*@lXHFYhYl|V1JT`$XmqSzw|Br z9Omc}k7HR+m;#CPjf0JM?{B;NbI&s?>X(?tC8Sy z-|6`vI0mvnq+Xsw$00!=mnhBalv2Y#4KChKNN=_#?mqqQ0Qj_#{;Rzkm5hmX-3x6R z8WW=_qoaSGW>V(6w1dfQY=5(baUbDb?t8zHDD~DoTPs~D_&9%I>(`{wA6{EO_ND`* zRt_^$I?U~Y&`zEe+4w=tTzp$pclpOmQ%kuFpR=uQ&65W&N7{c~kGIcia8mR&+O|0| z#DuVOK@Ja}WbEhND{@ThlqPFtE4wr|ew4bCX(A$b4yByPolWpLagF~iNDEP*Xpl zH4BtKw@x!%TCEy(j%;k-8eQGniqc|5I&R#WXw429?tOdktnXN?jr;J=H;|U0g~`ll z_eE98WO1GaJ?Sr_53)V0E~y=jvGVjJ8dPkE74FE|zJ`&2`q5j#3zfdVZz!a^$g?x^ zb!T84;M?pGF28L_z>N?5nUb&89~54k%VJ~GcqVOnf(pQ=d-v|GWJmt{ z)qN;GH(6_@7 zV)~^k*Sw&z(xJcx0|V_6mHsY`hgkJ=X47x^<$%j4g=}pu_t8}Toze<){mFjgj7?M| zuAK=C81$u=uXvlLspu1iwkt<1<@iWBq+3G?nO#&II5{aP$+t-X|0~xb*KEp2kMoh5 z8GOaAq+W{xr2r@12+kUPC{y57hw=NdE_Ik9EOr9v6kWtPu=7(qSmU+lCT|U1g4c1w z$d$>COSy2;4A2cwC;CaeSM4!19lQg6%5j)FEm_r;s#SnJ+tTtK*aF@yr}J>aL&|q# zlRZ_TKgEA!Hy?yWb5)5qlv1zTy*{7NF7(_2p}E^OQifB92l7H zU6GJ31H&V#KAkhBo!lhg@m1sf!|VGPyA>H=_V8A6^`a=%aYaZr#1pVKuz65VrLE3UbA_bZW!_HIP7I^Fw5DhXRiHoR z^&Uds=sD?%WC@u@z#5K#4~f1}fX!^Z>vTIQ9So>4n(QuD)}$@wn4wggGH2x*npHYI zS7mj)v*PT%iqzBY68HjGU|BLwJWZbsmUa#;?9SXnK?e`U$Z3Z{`iDFX5Z@Jgq&AO> z$gVWcU@q0x^wlS{{8*iOT|lJ#ZPEq?IAMhSMJ-}e^Vdyt5&$H1)E*?P^1U; z!g~}D#OZMgRN2Gdht>TC2Xb!3m^|w z4itMVNc4H8Y>#(9px0dTTYkV>tsUwpNeTL=M}cq_u9j z)J3W2nTn9TUbj~8aIO8}q!t0ut9v(z4IC-OW!Y0qC(Gr1+}zx>7tUkRI2ck>|AvHJ zcD04tEB(INXK6d|v*ywS@OK>WM`p)Yye{g_pTBt}`g#HU)Z1QoDmXG=;XSgjZlV$P zz`SM`6sarS30{+^Y~LhJ#P@E{xYl~&Lgl@Fpw%uo4oO<-j`y8ar{IQif;zBZfY z11XFY-)NvS|B>ohWaV5zZD-N(hI!?_ENL%=w9^$-WEFY6G1z?u<7kn4JJB!b0&M|O> z7vfO?QT-=9Ln{M&X<@Eh8hoi3?P6sWYx99*gj3Zz;IMy}FLstxI#FB~K64~y%Ovxj zOthjL3h%M=uSr*e_uG|uWdM|KXmchr?E+d<-9+uN!$_`jo9kF(EW~tPXmQ2ol-!JG zRZ*3XOUkcFC8(Zd@rw?#vYVX!sB@+p8(l`-FMha@tY~?9N&bm(@0FHtEGzG_Sx>dT zuxi%>RdY984e0B>3Y(kjr7N@+f$swck+Zg_<4UOGaBG*klnz5D0u0Nrwx7IxGhXJ3 zj>;+O{ilG$>QD^IbM`wQmId7$TQ$_Kpd)}hBNU?i@+3{(y7FJYbFO@pc4ZoIo65=5 z`0U$q?l8;of_cwIVk`d9mvq<+%5Tdu^80)Uk7=2J!f z4mQ9FZfyND*tcy_G)+YVH1jlU?e{hu*Aum1kz_F!8MvxW+v+eBpz^eq$bh%DTp(~hbFk8bGm_sZCCkad1!yDs(?S#f zwDOfpfy;R|vD zCH>w}cX20aE%g@^v3QWj($fVwkAGb?E;#bchUm%jfwiVijCA6peAmUTl19OS`htzv z9fUJueEL}Lc1505PyC93(kX!iuxlxh?!CMN(Lfzm_vIR}K@GSK-#a6Z_;*xuVqwoX9Rx{{DR9TO{^7)AkOz3(Lc%t8b%>Av<d3pxYdsgmfmQU-BBAfR^WPt+2TQVu?ZJkV zkCeKRKA6b~vY#npajFoqFj%grdQAr&PKFZ;^Ee9X=fXVm3|o|lwjgKAEzW+GpsG4Y-t*#OGZVfe(`c^3FHl+__wSe?0%bm{Oc#rAqa+9m8YJ zGK!J;KNXyxu}kTck>SC#io&t-nEz&DpD3P4?QC^)%x_{$gpaHW{PgPz%X|wh)qgE9z6KDkW;;a|Y!#kZr|HS%c}sYP%EY zHgXilEJXIB2=AXcMosiB?KTt;<9uYr?OmHSl3wH3Wh^-8DZkw_;%dwwmuUeHmmG zE)s@biP7vBuNS4t%V$$=tmhI3AZGu zm?vCqoq=sx>=5Ymv)tNfxtX{_Li_gyHavL^qF8$RaFr48Y6N6rla7!wJj0(z9iqMD z&%#n$zslOt!cG3&D?NJ^ND6<3iSA~r@k7=0)}sfXO$2kv_A_8?R|VUKKte|UD+4&i zRLgGyo;rJy9P~SLkLR_bW%$M-co2sP91|lrwSWgJ10SS|$o7~9FaJIN=(l8etUDUq zm)>&}@}mA&K1=q)tV?O{hUevBaDPBfV&k;ZQ9DwGKIpT#9mzUb?VB1vxnY&VVH5J1 z?(I3mr3Z+$6T5z6k42QZh=`=KJ(^bI1smp;vI;#J}UT^t#T2sr@;h zrrtG_BNn%p&e5E4&Y+M&%+pd`yJBOavElcUsN|sN+ir*&D~Mfm*-#Ky+V<52@pL<^0iMXZ#m4iC{>QTHvsmPC&h5 ztQp>kB)LF-*l<{VrVFQa!0k>mSjp?$wP5aVc@eE%~C5-nRl3c=`;Ukc=iE%<~@1Qtgs#4Ntb|U-2>g0~J zf{(%7AMYoyn=1Kl>Fnd4pkn*&WwBEdvfS_wvHWNmLG+C|hxGMjo+_9HqRI8nOH@ER zlL$MV)1U-CWcfn!oFXV5MW1o^jb_K&cgnq1bNz;~dWTXctSW!PmG&?SfMU$U$pe}5tq&>C`DV#(|C8j8m|q&Dapn6=yJu! zr(^$Qqya&JdDh*W3%E48L}M?=6yMf4m?~~Jv5I}g@t={Jw?s+p=FL3%j-4s{{%T{; zKg}sG=zxSIjBzU6`G(>!TS+@@9y{e={nG{YXD|ga<8)|^r07LuQ~TAMdG7EFZa7kf4c-;duotKY#=y!YXALA6dC3q$e_CawjgOwq z1*$9vfe?gY}%i-!&CXbgKL=1SBN%a=bkO*dW5h+tvAdGL^`)YoX_D z2`qY|-aL*n$vL5n&Wm?v?$ZGfZh|D43wZw@zcxeJvN(4Zp7euXH^5{sHO|z{Hvw_O!LxWzd}2SuR&^qkaSOU(*czYwBwFl zwN%3NL?DE}oC)J`aCY=3ip5V^s6lAfUPc3l%~t$;GP-H4|H}Ql`E3NzKq8DPPEYZF z7yKn+DtiyTtt-&XEs@cr<$jbyih0F6{H=TJoPhXU{m(A4EwP_wG}o;L=*R(&*S}d( zI!z6BY7i*gjhkT_e4t#tDY%U#3O4nE7r)_Hl@gJUA*5~-q^z)#doMkgSOV*PZlLK5 zaDVAto+JfL<|2Kc4XDKKY&zf0#vu`!4Z(e*tKqGRbG!Lp-3PwP%_=@{+bCL9(^)08 zJKFa|s*qcF2O&@@qlnwDCo6rTq#=2_xtQ7}pXngsvKVf!4uB>6ZRye|wsVJk*Sa^( zSnsW9yK^bWap+FR!`RmtdT&ij4%$YNv)W(kW^+Fyr`q|Ff4Sw4ky_ljR{^<4{o^0-Z}l)94y3c*p2 z%tLh({sd{%Z8gf1OB@AsxbF9fk)!GLoM>jt;%4r(mrQr;=^9o!1mvti%jIpX&!Bd zJ8AhiFRAC4v5U43N#5$hn$yS*86 zx-QGy_=ilrWD^8@E=YalD75t9(&WT(-P{-iht&MYVN%y+P2I8x5j!5%R?gWyX)7^I&AJ7d+%8PuGx(O42|%jBpRkk7I8cp(mMoY+*WP1uyU$u36(& zg4LJPwK$B#1(^!D4JE;}Cu=;;D)E7?WE2Q*$u()s=$*U%s^TSa9|g+VWJJ@w6w?W zAtC0wf7cP`G<})BUbf)&zj2U@MQ3b;PY-R7OG`?o zyYtNOIa+qoVTBnR{o)f0Eq;r*TtIL6h)|%~1wtghptJK3$vcZH&^vLd@{us1<751$ zqu>8t(Fhr43=FduTgz+3t00<>;s4n5U60M*j+O=AVgewSI^I1BZ)LTgL7A`GucjpK zOBCVPN?)+DyWYbM+lnPZKDk-2IPX3SyCz~?R%R#zB&WH9q5m?Q6Aq4%QFboL0jgPD z`z)rVIp_u0g*mOh7twc4%Dmg~{Cmf%?lFLo#c|(Wc!)_K)*}G^ysv!9W76;B3Q1G=gk#- zjBf^^v=<4<(#L%Dg;847WBq^8!?08T;eh{!Z>NkVT2p(bTGd(O$(WhZcM0q+MJj9R zU)ZqzrFeg?8LC~2SpgpBJ?WV>CwfqF#fJZd8NC;J0Vqv)A=PhOmN*D8fAep!xW6?e zu&yN=Hr{+(J^t-~jjtdRQz=^U1TjtKHn~@R5;r(qH_4)#5&E&&*>sT3alO;O$A!(C zyb;-H_+NPWA0$^LBD%0{_kvAfp#{sff$iuY_a98_5|uzTA-W%0nFoY}LNZP_iO9Z2 zxBIf=S?h1IAxKZs(;S$L4#JXub}G2Hh#yicr zIQhe+#8F}c?>U(ZK}%JUS5BMiaLxvf6$W=c!4sITmcz`TY;( z#y3g*sYUOf8b8E>9a%ZK!_TH~se1m13~$(`bhXHhvmvlW_jA>z<&DNCl_^&4`H6xR zy;-$wUX{9n6~ZRc9eY zZR#wi89iPBL61?;Jqot-0XYzfljUY|*I4XTbIrPyqiH=Z;|ayrd-~%tW_%0ShOhf; zt_1sZ2^~gdxg|``$z`SS1i!0uQv~mJyof`-1pfzm5h2QEGT}+wZVMTyAZvCRGQgJ+ z?dzLQ>KzqH{UX}{t@leUIb$n$zg3PbQ3m4iqa)a92x>E##py@tr!D8K!4we}`OB$VxTu{OB9D1X%s0TN*e@ z{Q@Gp<#ip-g~c4aYkC!1r~CbZIseMZN&^!ePf}}7gRE2A3tm8Q@{7JzceR)EjwTv{ zL*47jcY$T#;}+2z=+#Jzxl-h5Y(cX`UG&rxVXy13(Z&JL&xIWl8}$V<^2O{+kw`fS z28=pe7?PZBg-lgto5Us&+Z{7Mvu5u8o+Bo#4D&4fT<45zJFQ#th_2RAN-D%_Pd|K8vqfnYjx$Wn`RkB`L#a3(|#NYTWUbGWbptF2iTrGKFGG{DpB}<%yotO-LzN|_atO{&{K_l^q z3aNKwW1U`V10Sx~#oQuu)bYbMnl6*85y&AYHsB7h7b<3lZ8jb$vx?mY%?n1a>v_lo zp0i>eVV_bDiyY5Ez0jpoCTK+usmlesU*)wr7cw;QE1$?JvB77P+rN7)%RI#C8>+X zfeQFE-t)j2YbuW@FGBsXJnK^Q^hsy@v2q%ciLJ1LS#Ffj_*fS%y=^27uEaDHig6PQ zzJ)LC%?jR8CjN#&bKU-c$bizN{{oR^*CM3R1Zk`Q=*Zfoo22j-+3?e9DnM3u$LlL(;ZJDtBa2bJ1Kk{-(XTCT8gQ?ARVAh>g+~X znTIeo6Hi?|Bg0=1zuDo}+dI-GUv2VZ zt@X3w1{7>D?lL#T^8+w(TI^-aj8^jziz|{{FM>A6%?*($1Goyr4Y&`Qpz{ysD(n&M}GdOhp!FWWa)v zZZyN5N&pBK7qW6}XW;IqM|@XuGp_DM+z7z%yL0LGj(w5~(O<70>f&EGQBs$4SL)f> zq8$kB-3gzaT?b3C%|VJ0wRR<}soSdl(k`y+3Xw;_gN0}rtV(X@A8Qcqv7=UYwqxP_ zkCwMVkMw^(sp|=w@T+hlZJMfOHpYPjRrJLv~+!sUKs*7>6zEDR9?f+k!{q2&8B~#))AsUggPTXCw|Cw zOuLI=>Q}v9eX5!~CqB?M+AFj%`(6m0J(sSO{X-i^<}WKB@Sb{=N?wrh)n~fLmPUPV ziIQ;hIlzBaNWZkkZSkTP8PFTRAPjJVPy~WRNzL-+sX+yhn&RYAmB}Dr%ACe4MMmNq zVhpmbAb08W^x?hzd6Wy33li+AlNLqaYCU)2pDGLpe4TCqh2pvQjqofy9>tGo9q7t% z0T;}|Fw*dn%Qdb;EmT%jsKhyw#lHtbon-jStdC#LFmuKEH9tHa$+bRd+Ls^jE$>0J z8qROo(6I0*ZQ5cz@N>Y#kro;mYd$?pP`Nv-uS; zoSbWN>N4>jvdy~9+OTF{OEO@L^jkg;?uXdZ=3?WvUJR-bx*-86nZMoXevyLdqOtLl zuhQKn?Ua$SKqdSoy@xqp zHNs`Sw&*I&@(6CMU)FKn+l#;|H?9;_c-Iy}mGs6|c6PVG4Dgv*da3Rz zLiq|g{nn-GWzd5x*XJLfawQs~fU!RwnUa3*jq-<*ldxvCxQ$Diyv|5XL?gQKbH>8H z8?6_9EVKJ{C_*;fzcayB9y(e84K@f>OPu2O*uu+9GVDOoYG2I_AA4O?r1ZqbzOx9+ z!53Lz7FNrjPCvrufPhO!26K~NSU?TUxko;gO9U215_7&Wx3w!dyOz(%>QSICB?#$t z-cxslthuT(b}hulm9EFPj2um_I0a*$POAhnbNOmbppU7t4_pT3`X}%E zSfd&Jmm)?ptT^2dNXIHA#vR-nyJOggCiP^-1?Ml@O%bX@>CH3jayDA1+EU0%gWD^S zz$mG7lA9^%-n2KU1p-e3;QITebW?f%+7 z_$}*X+ApS>Zs`qLzDHbLd5eS8;^jo5`NAaN3Ttne(YuUm*iusg${gcjru}-FM^Z z)t>?NyxwKi<>#wFjH$FW^6ZlJx52-2RCNrcd@v3mgNa2e#?i}^I3Jr%*&fj6#@eCh zw`RvIvQ{$#U2WA~LcRYgdX#ji*3?Snki~L`haI`jed$?Vf9XZ)uCazLi#HC^pE6HS zWlMgkC(GozzNbzbJI3*z4ttqs%A5?oXwNp22~^Fw_VG)*e_j5?>()g!(Mjm3sHneG zYH8sCrJbARul@>GL{>X3FxS^n5%>!~+YV7^|KitPXmOX9bYHAoyv20D4-nPMj2oE6 zQPmJT7FEC%=mJ&xAM5LbmF4~3@UQlORyu$t>C&V#$dQTsY@oX${Y6sN!fNN-1vYr5)F>`p8rd?6ID#WL}TVsuuLfEwsdx{v~z~HYKX> z=Q)rA1nb{mzLXl}MvG7XvG}7=*L)7Y4~eepss<8$oYJy&WoGUi{~p z51EFKjZJ)dW7k>mqWQ~Z1viU=z!ELH{6h-uC!{AyZY1lNgfF?diZ_bH5U6n?iHd^M}daSVEG0`t#5N)@47eB z@^#$F#MOMJ`$1`4?d9u%5ob`>ps($4#QBeNjdHQO8AAH1Lh7F!P!nj(r6&Xa5j_RB6ozmBfw>vV z7yhE#U?_xLq%iX?6!WIA?E@du`&Ul*d~V{GAJ`EgX(F7Sx3)f#D4*pQ7tDR-PUXA6 zLR87d{_$!$xbx+z{U097&YKOEM#~g!WGaB{BmJKGgIO~rpy4wzl`U_caXmPk0lxzF zJO>fm9XBoN@D?}mD7$xvHXJyIto_)DZg`hOm)Oo)E7+6A=JEjo5?c`0^2K=qLEs+0I?T#=UL^+Rbmq>A z3qs>02J=In|LEusyvDwg+jc+w;i>pBdBhZ_BMEKf34@+-A^OsdmFpa zTve7C2e}CZrt3XtY=?wSa_nNu3_)zd2|Bh$;aCq`)wLx6KlGZN_lTrF<8E+n)**L> zIe+<7-=H=5Hr~_28>D>aLGCZ7f>K5w<&GyuIJ*>Oa5rX~}}u=b}m6DIbvXtFZ#!}jYvK4N8PK}@TZ0i~NL zxnRP+cvEZ>pBj=*kX)E99ViLPfT8m`SgH^^3(P0NE;Jg(y`d&j$F{{AFVCpCmQn)j z(aG-Fb-$l~>zZFLpsDFqU;t$nGc&vhR(*j5dcdSAI<3uQaRH1^UO;EfK|!*ux&cmi zrf!rtwvqh0NBc+P=w}Sipb(`xY1alMS}Y<>??mGhlE+>^uu^}2jyK@;j~uca)`HsQ zH7(u3uH&_wBN*POzROJf>;=3t5>2~gx8B7#DB=ThW7;p$OCI2n z+mPsYy>1@i?>ZYhUu#V4!UjXv{E(iVbDrlQ)W2-lvIz%} z>qqisyvCtv!N~mx-xWIsfp8CiBrz!`jQM(+{vmg=?&O+~FgF zdGuxHIk3BGVn(upRtX?GDOFL`nvr$%9I>Q2Y{=E^md)bLy91%hUeJM_*zv>uRQQx= zzps<|<6A4J(uWmC1rBajWsyYje(2>rI3!8rr{m2S*P6>yy39UR#5eHEUX^bTLG9C? zuJ;zQUuijBnb}6)+_59K;zGZd zPJ0bpq4q?8W4jBP?j2|gNE=|{PYcwiH-My2#l=g$V)Ld0YxRoiLfmiDD4u_&w71lq z8T6;0b;VsO4A*}Ys3V#L=&^oynF-5Y`;_iBa&`b%eBb!a&|S^n3e}0#KD$stbu#Gf zu^r^`?aW8ZMYWdlpF2vjPgE%!7FHd*xI6`w{ExL9NBmO?pHRQ(&x!-Pkn8!`qPbN}G1G}7pi@s_{_AXe+L_lw=oYTE;`!UDlxHQut z756^Nhh>LfPWHL&E$0^Se$Sj6N;4lu&DZkB!o-D4KaSrz4lDOg6(2kxLh6E7M!)Ms98OJ&TzcVrYkos+Qw0qC3=RKzCN- z4z!Rst)llNUGop-w-4s=<_{)@hk5|GQgxus(s!Y2S7eL&aq)L7Kg5+UPbM_h2NYJ` z(GAYt(=ew2)n~e@bB(qvq3M5?@VT&>NBCL6>sE+Xm`0=To;*&kyf>SvaC~qWWqErE z`3o)Ev>S^#Q6RSS+1ZR{2oy)4Xz6>)%*vXaT zeK4~Rd8YPDy+ni z$kMOi@U_vdmb;iEGh*cCJF|wQV{%{;{2xr%Ghye#Xs@$le%vx`#isnGyWv=Dl5A@! z%yPA&B#3y`6PZxAba-;lkZ?UA(UM*wyojy5!rGEd6*fTpxX3Be@gy507DCFt=YQC&x-HV7V-BUgmlqu z0jylzIo_<6bd}zh#UE^+@VXBNb<$4IALTVqeEr&qwt7D3 zabx&-sj!!W%KEy8N3%?!_{Sq1wDgIAv^voVx@-Gb9_7G`dN z-?gX2NxE+D;(+RoaweM2pZ4_jh@A(0lexpZM{bVzIB);r{DyU#GV7C6nZO^3b>OcX zBUbw-R*EUz4&M@eatiKXX8wjdoXtfH@LYt)vx)U1@odk=a^@0D84BuzE@s;(d`@IlUToja;d2?JQ&5(s2ozuWrvdIS;n1#t%}k#_a1K>c|&`Pq@k0hZW%m z@!G;69Z<-gs`ef^p(8lXGuO@%l~u6_bPA{{a+1Ly_0Ad^3Zd?@dS*QRpm`tf)b6>D z*ISx@AAL;9cX{z^1|mD@aJ%SIb&HVq>E^E6+IqeKR(MvxOqCrMEBJdD$5nJD;MGE? zt?{{)UFEmi#-yMD1H!BmVnE136WSJWY3 z^_~Zmx;pIyDf0B)GeQ_{jAa)c{~}6VQ+t!q5aDSLrU=T`^?B_ItXim8p|_8)6nbP} zc?EVD;uItW^^Czvf&;&JV)*We)tdocxUZM++J?r=VMNthhNc#r-SNBA>X+TJa$_6_GU;${coM2E?=&IgXuinmN)G~&Yl`1zyc>C zgV9_Gq*&P80Pn8C3u9`fL$08x`gIQh%HiRVtVJV-#a`+dQIY%3TEGtOFoxB90hEk{ zWdfV_AT%*L6CPmR?*}}-P0OmyEAPWIU)B&!5U!GD%>CPJ1oseLX3!VI_iR8BK!t!O zbG#3dXf7Ntk|G!O6S?Vthuo^))QTS4(!Q7vo(h~A*HpOPHEr5?K~L}mI1k3^uYZn6 z3US9PaDJN~;iU*tpcVjPPP_?gwfH4$iUyPE?Xerrb;GExFQf)u`UyW(<4-;j6vn+w zXZhplV*PiaMRl{~cG3cy`;_l1ta)6E$*S>dxx#`jI+AyF9(^HT(^nTMj)6+8fX#hN zACgO!HDprNdV(>tEB*ONs}+0@-~*`6odl+u}enfYR{s1B6?kI0t{>YE9g ze={b-Eos3;Q2!>v9Y5^&VrsQslGQHbJaed|nf>Sg`zh2yDn}vkTuVW@MHHF_gw%!JmWokTzqhxnT>B3H7^*P%BT3$H+IWM}wl6hq8HFOJ` z6CO|)N4AO;F)v^;9xSuqghCpOqfD38_L(a^Z~pOjD4O&ocQxTaa^5+uLLj4G*-9E% zE{%z=TxMsuR1%Az~CBf2497LfkgEIOB%v%lRaJu1u6RBgfpTd-JSs z!iUv4J2wafs8Bo;`?)fM4Do0G1~yG_v9(kCheUR5-zrX6K!pRhD%yqR&DkE?+RnG^ zxS{ukoF~0|>xGNQ-_!qnf#~#Gp-tYii?DThY}WOk7iu0KI2-a$FzLNa?mt$LXJdKU zZdMZew26GnqOROKb1Cb+g}prRLZ5Rdx9s zvzn)Oe{f>O#4_6|$8MY>_aNLC zErAbg^9o3pXy>%cBg=sM=Uk$RPxCLGYtQSt2Na@#KVaLBDUa<92Ke8&&ZSc@2}yn} zxE{gu>!nS>*jFM;OsDovsxH%Cl_F7vWa@2ODw=5aBWJ6|*0`*pt`DpwnJb^Gh1@U# z^iHlunL|AhjGQtfDzL0+BmrVoh|op20|Mpz0m+ zX&P4i&9D6U=zi{~6Y{crkLsI7IY42-1$Zr6ypcI|q6^5l3D+0AVC`4G&@+2%vQSC5 zoMWwf`LYWNaEdE${(fYMls5R35Xt-w*#wAtlP29QF+E9#4hMzyf|UEck_jnStp14u z&j!_RN0(a=+Tupu%w};C@*oqp(A4h(9(Ut1<}WoCIc{Ico39Vu#5K9{G=EK&-m7m% z%vrA>NQq&pu96Ju3~3;>{ILIQ FpM9ou89oV;b5Eca*vLo#<6{~%iTY)O)Z@( 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 sPpdcV0Zhn($&0bNe4%m4rY literal 0 HcmV?d00001 From 1795d501d995f037af9ad81d8731f14b67a7f19e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:29:34 +0800 Subject: [PATCH 0903/1224] add docs --- website/docs/artist_hosts_3dsmax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index bc79094746..6f23a19103 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -129,7 +129,7 @@ Some validators are mandatory while some are optional and user can choose to ena in MaxWrapper Class. :::note Users can write the properties' attributes they want to check in dict format in the setting - before validation. + before validation. The attributes are then to be converted into Maxscript and do a check. E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` User can put the attributes in the dict format below ``` From cfd9f0f06c26c0d47340f9baf42239674e2cebc8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 31 Oct 2023 20:32:28 +0800 Subject: [PATCH 0904/1224] edit docstring --- openpype/hosts/max/plugins/publish/validate_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py index 0cd405aebd..0632ee38f0 100644 --- a/openpype/hosts/max/plugins/publish/validate_attributes.py +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -40,11 +40,11 @@ class ValidateAttributes(OptionalPyblishPluginMixin, with the nodes from MaxWrapper Class in 3ds max. E.g. "renderers.current.separateAovFiles", "renderers.production.PrimaryGIEngine" - Admin(s) need to put json below and enable this validator for a check: + Admin(s) need to put the dict below and enable this validator for a check: { "renderers.current":{ "separateAovFiles" : True - } + }, "renderers.production":{ "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" } From 00fb722089a80dae83fe89b387ddcc481053f053 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 13:55:22 +0100 Subject: [PATCH 0905/1224] use AYON username for user in template data --- openpype/lib/local_settings.py | 6 ++++++ openpype/plugins/publish/collect_current_pype_user.py | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index dae6e074af..9b780fd88a 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -611,6 +611,12 @@ def get_openpype_username(): settings and last option is to use `getpass.getuser()` which returns machine username. """ + + if AYON_SERVER_ENABLED: + import ayon_api + + return ayon_api.get_user()["name"] + username = os.environ.get("OPENPYPE_USERNAME") if not username: local_settings = get_local_settings() diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index 2d507ba292..5c0c4fc82e 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -1,4 +1,6 @@ import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.lib import get_openpype_username @@ -7,7 +9,11 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): # Order must be after default pyblish-base CollectCurrentUser order = pyblish.api.CollectorOrder + 0.001 - label = "Collect Pype User" + label = ( + "Collect AYON User" + if AYON_SERVER_ENABLED + else "Collect OpenPype User" + ) def process(self, context): user = get_openpype_username() From 98f91ce932c4b544f0cb6d54f6010f8d99d493c4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 31 Oct 2023 15:37:15 +0100 Subject: [PATCH 0906/1224] Fix typo `actions_dir` -> `path` to fix register launcher actions from OpenPypeModule --- openpype/modules/launcher_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index 5e14f25f76..4f0674c94f 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -40,7 +40,7 @@ class LauncherAction(OpenPypeModule, ITrayAction): actions_paths = self.manager.collect_plugin_paths()["actions"] for path in actions_paths: if path and os.path.exists(path): - register_launcher_action_path(actions_dir) + register_launcher_action_path(path) paths_str = os.environ.get("AVALON_ACTIONS") or "" if paths_str: From 711976e68586a6110b04a0b7d650effca08dcd30 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 31 Oct 2023 18:05:20 +0200 Subject: [PATCH 0907/1224] fix bug when loading shelf files --- openpype/hosts/houdini/api/shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4b5ebd4202..0afc737665 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -44,7 +44,7 @@ def generate_shelves(): "{}".format(shelf_set_os_filepath)) continue - hou.shelves.newShelfSet(file_path=shelf_set_os_filepath) + hou.shelves.loadFile(shelf_set_os_filepath) continue shelf_set_name = shelf_set_config.get('shelf_set_name') From dd070c6fcc0ded177fefebcf64f5272bb4d007d3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 31 Oct 2023 18:06:06 +0200 Subject: [PATCH 0908/1224] Enhance shelves settings visual appeal --- .../schemas/schema_houdini_scriptshelf.json | 24 ++++++++++++------- .../houdini/server/settings/shelves.py | 14 +++++++---- server_addon/houdini/server/version.py | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index 35d768843d..f45377c8b4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -7,22 +7,30 @@ "object_type": { "type": "dict", "children": [ + { + "type": "label", + "label": "Option 1: Add a .shelf file" + }, + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + }, + { + "type": "label", + "label": "OR Option 2: Add Shelf Set Name and Shelves Definitions" + }, { "type": "text", "key": "shelf_set_name", "label": "Shelf Set Name" }, - { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path (optional)", - "multipath": false, - "multiplatform": true - }, { "type": "list", "key": "shelf_definition", - "label": "Shelves", + "label": "Shelves Definitions", "use_label_wrap": true, "object_type": { "type": "dict", diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 8d0512bdeb..e02ddf1c34 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -24,14 +24,18 @@ class ShelfDefinitionModel(BaseSettingsModel): class ShelvesModel(BaseSettingsModel): _layout = "expanded" - shelf_set_name: str = Field("", title="Shelfs set name") - shelf_set_source_path: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, - title="Shelf Set Path (optional)" + title="Shelf Set Path", + section="Option 1: Add a .shelf file." + ) + shelf_set_name: str = Field( + "", + title="Shelf Set Name", + section=("OR Option 2: Add Shelf Set Name " + "and Shelves Definitions.") ) - shelf_definition: list[ShelfDefinitionModel] = Field( default_factory=list, - title="Shelf Definitions" + title="Shelves Definitions" ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 01ef12070d..6cd38b7465 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.7" From 918770f817e5ab1a1c930bae81a441609b4d5ce2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 18:57:28 +0100 Subject: [PATCH 0909/1224] 'get_current_context_template_data' returns same values as base function 'get_template_data' --- openpype/pipeline/context_tools.py | 93 +++++++----------------------- 1 file changed, 22 insertions(+), 71 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 13630ae7ca..5afdb30f7b 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -25,10 +25,7 @@ from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy -from .template_data import ( - get_template_data_with_names, - get_template_data -) +from .template_data import get_template_data_with_names from .workfile import ( get_workfile_template_key, get_custom_workfile_template_by_string_context, @@ -483,6 +480,27 @@ def get_template_data_from_session(session=None, system_settings=None): ) +def get_current_context_template_data(system_settings=None): + """Prepare template data for current context. + + Args: + system_settings (Optional[Dict[str, Any]]): Prepared system settings. + + Returns: + Dict[str, Any] Template data for current context. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + return get_template_data_with_names( + project_name, asset_name, task_name, host_name, system_settings + ) + + def get_workdir_from_session(session=None, template_key=None): """Template data for template fill from session keys. @@ -661,70 +679,3 @@ def get_process_id(): if _process_id is None: _process_id = str(uuid.uuid4()) return _process_id - - -def get_current_context_template_data(): - """Template data for template fill from current context - - Returns: - Dict[str, Any] of the following tokens and their values - Supported Tokens: - - Regular Tokens - - app - - user - - asset - - parent - - hierarchy - - folder[name] - - root[work, ...] - - studio[code, name] - - project[code, name] - - task[type, name, short] - - - Context Specific Tokens - - assetData[frameStart] - - assetData[frameEnd] - - assetData[handleStart] - - assetData[handleEnd] - - assetData[frameStartHandle] - - assetData[frameEndHandle] - - assetData[resolutionHeight] - - assetData[resolutionWidth] - - """ - - # pre-prepare get_template_data args - current_context = get_current_context() - project_name = current_context["project_name"] - asset_name = current_context["asset_name"] - anatomy = Anatomy(project_name) - - # prepare get_template_data args - project_doc = get_project(project_name) - asset_doc = get_asset_by_name(project_name, asset_name) - task_name = current_context["task_name"] - host_name = get_current_host_name() - - # get regular template data - template_data = get_template_data( - project_doc, asset_doc, task_name, host_name - ) - - template_data["root"] = anatomy.roots - - # get context specific vars - asset_data = asset_doc["data"].copy() - - # compute `frameStartHandle` and `frameEndHandle` - if "frameStart" in asset_data and "handleStart" in asset_data: - asset_data["frameStartHandle"] = \ - asset_data["frameStart"] - asset_data["handleStart"] - - if "frameEnd" in asset_data and "handleEnd" in asset_data: - asset_data["frameEndHandle"] = \ - asset_data["frameEnd"] + asset_data["handleEnd"] - - # add assetData - template_data["assetData"] = asset_data - - return template_data From 4a11eed09ba8936351ce8acf1fa06fdd0ef904fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 31 Oct 2023 18:59:06 +0100 Subject: [PATCH 0910/1224] implemented new function which does what houdini requires --- openpype/hosts/houdini/api/lib.py | 56 +++++++++++++++++++++++---- openpype/hosts/houdini/api/shelves.py | 5 ++- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index cadeaa8ed4..ac375c56d6 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -11,20 +11,21 @@ import json import six from openpype.lib import StringTemplate -from openpype.client import get_asset_by_name +from openpype.client import get_project, get_asset_by_name from openpype.settings import get_current_project_settings from openpype.pipeline import ( + Anatomy, get_current_project_name, get_current_asset_name, - registered_host -) -from openpype.pipeline.context_tools import ( - get_current_context_template_data, - get_current_project_asset + registered_host, + get_current_context, + get_current_host_name, ) +from openpype.pipeline.create import CreateContext +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.context_tools import get_current_project_asset from openpype.widgets import popup from openpype.tools.utils.host_tools import get_tool_by_name -from openpype.pipeline.create import CreateContext import hou @@ -804,6 +805,45 @@ def get_camera_from_container(container): return cameras[0] +def get_current_context_template_data_with_asset_data(): + """ + TODOs: + Support both 'assetData' and 'folderData' in future. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + anatomy = Anatomy(project_name) + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + + # get context specific vars + asset_data = asset_doc["data"] + + # compute `frameStartHandle` and `frameEndHandle` + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + handle_start = asset_data.get("handleStart") + handle_end = asset_data.get("handleEnd") + if frame_start is not None and handle_start is not None: + asset_data["frameStartHandle"] = frame_start - handle_start + + if frame_end is not None and handle_end is not None: + asset_data["frameEndHandle"] = frame_end + handle_end + + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + template_data["root"] = anatomy.roots + template_data["assetData"] = asset_data + + return template_data + + def get_context_var_changes(): """get context var changes.""" @@ -823,7 +863,7 @@ def get_context_var_changes(): return houdini_vars_to_update # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() # Set Houdini Vars for item in houdini_vars: diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4b5ebd4202..5df45a1f72 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -7,10 +7,11 @@ from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name from openpype.lib import StringTemplate -from openpype.pipeline.context_tools import get_current_context_template_data import hou +from .lib import get_current_context_template_data_with_asset_data + log = logging.getLogger("openpype.hosts.houdini.shelves") @@ -30,7 +31,7 @@ def generate_shelves(): return # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() for shelf_set_config in shelves_set_config: shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') From 9e85cc17a989fa88d4df8a5a8644ccb30470270f Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 1 Nov 2023 03:25:26 +0000 Subject: [PATCH 0911/1224] [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 d6839c9b70..4865fcfb31 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.1" +__version__ = "3.17.5-nightly.2" From 15414809828905c24f89ce67547b101817d1309d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Nov 2023 03:26:11 +0000 Subject: [PATCH 0912/1224] 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 73505368dd..249da3da0e 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.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 - 3.17.4-nightly.2 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.4 - 3.15.1-nightly.3 - 3.15.1-nightly.2 - - 3.15.1-nightly.1 validations: required: true - type: dropdown From f330f87993d6826b519c7ee949c8c1a2c21e197b Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 10:37:41 +0200 Subject: [PATCH 0913/1224] Add options to Houdini shelves manager settings --- .../schemas/schema_houdini_scriptshelf.json | 141 +++++++++--------- .../houdini/server/settings/shelves.py | 11 ++ 2 files changed, 85 insertions(+), 67 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index f45377c8b4..2dfce906b7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -5,78 +5,85 @@ "is_group": true, "use_label_wrap": true, "object_type": { - "type": "dict", - "children": [ + "type": "dict-conditional", + "enum_key": "options", + "enum_label": "Options", + "enum_children": [ { - "type": "label", - "label": "Option 1: Add a .shelf file" + + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + } + ] }, { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path", - "multipath": false, - "multiplatform": true - }, - { - "type": "label", - "label": "OR Option 2: Add Shelf Set Name and Shelves Definitions" - }, - { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves Definitions", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "label", - "label": "Name and Script Path are mandatory." - }, - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] } - ] - } + } + ] } - ] - } + } + ] } ] } diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index e02ddf1c34..651af27537 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -21,9 +21,20 @@ class ShelfDefinitionModel(BaseSettingsModel): title="Shelf Tools" ) +def shelves_enum_options(): + return [ + {"value": "add_shelf_file", "label": "Add a .shelf file"}, + {"value": "add_set_and_definitions", "label": "Add Shelf Set Name and Shelves Definitions"} + ] class ShelvesModel(BaseSettingsModel): _layout = "expanded" + options: str = Field( + title="Options", + description="Switch between shelves manager options", + enum_resolver=shelves_enum_options, + conditionalEnum=True + ) shelf_set_source_path: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, title="Shelf Set Path", From 0e331db93a0de805a55e88711d5d880f6281715e Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 11:24:48 +0200 Subject: [PATCH 0914/1224] Adjust Houdini Shelves Ayon settings --- .../houdini/server/settings/shelves.py | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 651af27537..a0acc90505 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -21,32 +21,47 @@ class ShelfDefinitionModel(BaseSettingsModel): title="Shelf Tools" ) + +class AddShelfFileModel(BaseSettingsModel): + shelf_set_source_path: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Shelf Set Path" + ) + + +class AddSetAndDefinitionsModel(BaseSettingsModel): + shelf_set_name: str = Field(title="Shelf Set Name") + shelf_definition: list[ShelfDefinitionModel] = Field( + default_factory=list, + title="Shelves Definitions" + ) + + def shelves_enum_options(): return [ - {"value": "add_shelf_file", "label": "Add a .shelf file"}, - {"value": "add_set_and_definitions", "label": "Add Shelf Set Name and Shelves Definitions"} + { + "value": "add_shelf_file", + "label": "Add a .shelf file" + }, + { + "value": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions" + } ] + class ShelvesModel(BaseSettingsModel): - _layout = "expanded" options: str = Field( title="Options", description="Switch between shelves manager options", enum_resolver=shelves_enum_options, conditionalEnum=True ) - shelf_set_source_path: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Shelf Set Path", - section="Option 1: Add a .shelf file." + add_shelf_file: AddShelfFileModel = Field( + title="Add a .shelf file", + default_factory=AddShelfFileModel ) - shelf_set_name: str = Field( - "", - title="Shelf Set Name", - section=("OR Option 2: Add Shelf Set Name " - "and Shelves Definitions.") - ) - shelf_definition: list[ShelfDefinitionModel] = Field( - default_factory=list, - title="Shelves Definitions" + add_set_and_definitions: AddSetAndDefinitionsModel = Field( + title="Add Shelf Set Name and Shelves Definitions", + default_factory=AddSetAndDefinitionsModel ) From 381c00c3342193442e46b3e2475db64dda129beb Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 12:29:41 +0200 Subject: [PATCH 0915/1224] Align Openpype and Ayon settings, quick fix in shelves loader script --- openpype/hosts/houdini/api/shelves.py | 34 +++-- .../schemas/schema_houdini_scriptshelf.json | 128 ++++++++++-------- .../houdini/server/settings/shelves.py | 2 +- 3 files changed, 91 insertions(+), 73 deletions(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 0afc737665..6fb3967be8 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -23,29 +23,33 @@ def generate_shelves(): # load configuration of houdini shelves project_name = get_current_project_name() project_settings = get_project_settings(project_name) - shelves_set_config = project_settings["houdini"]["shelves"] + shelves_configs = project_settings["houdini"]["shelves"] - if not shelves_set_config: + if not shelves_configs: log.debug("No custom shelves found in project settings.") return # Get Template data template_data = get_current_context_template_data() - for shelf_set_config in shelves_set_config: - shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') - shelf_set_os_filepath = shelf_set_filepath[current_os] - if shelf_set_os_filepath: - shelf_set_os_filepath = get_path_using_template_data( - shelf_set_os_filepath, template_data - ) - if not os.path.isfile(shelf_set_os_filepath): - log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) - continue + for config in shelves_configs: + selected_option = config["options"] + shelf_set_config = config[selected_option] - hou.shelves.loadFile(shelf_set_os_filepath) - continue + shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') + if shelf_set_filepath: + shelf_set_os_filepath = shelf_set_filepath[current_os] + if shelf_set_os_filepath: + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) + if not os.path.isfile(shelf_set_os_filepath): + log.error("Shelf path doesn't exist - " + "{}".format(shelf_set_os_filepath)) + continue + + hou.shelves.loadFile(shelf_set_os_filepath) + continue shelf_set_name = shelf_set_config.get('shelf_set_name') if not shelf_set_name: diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index 2dfce906b7..cee04b73e5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -15,11 +15,18 @@ "label": "Add a .shelf file", "children": [ { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path", - "multipath": false, - "multiplatform": true + "type": "dict", + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true + } + ] } ] }, @@ -28,60 +35,67 @@ "label": "Add Shelf Set Name and Shelves Definitions", "children": [ { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves Definitions", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "label", - "label": "Name and Script Path are mandatory." - }, - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] } - ] - } + } + ] } - ] - } + } + ] } ] } diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index a0acc90505..133c18f77c 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -30,7 +30,7 @@ class AddShelfFileModel(BaseSettingsModel): class AddSetAndDefinitionsModel(BaseSettingsModel): - shelf_set_name: str = Field(title="Shelf Set Name") + shelf_set_name: str = Field("", title="Shelf Set Name") shelf_definition: list[ShelfDefinitionModel] = Field( default_factory=list, title="Shelves Definitions" From c185d2cab8b9cb1a21d17c26acfeb8180eedcb15 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 12:35:41 +0200 Subject: [PATCH 0916/1224] resolve hound --- openpype/hosts/houdini/api/shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 8b27ecd67e..5093a90988 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -46,7 +46,7 @@ def generate_shelves(): ) if not os.path.isfile(shelf_set_os_filepath): log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) + "{}".format(shelf_set_os_filepath)) continue hou.shelves.loadFile(shelf_set_os_filepath) From 82f3b5e07f9d5b2bbe2a7679f11c52adf5ad51ff Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 1 Nov 2023 18:40:28 +0800 Subject: [PATCH 0917/1224] avoid using asset from context in collect render and also clean up unncessary code from the collector --- .../max/plugins/publish/collect_render.py | 19 +------------------ .../plugins/publish/collect_scene_version.py | 1 + 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7765b3b924..38194a0735 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,11 +4,9 @@ import os import pyblish.api from pymxs import runtime as rt -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 from openpype.hosts.max.api.lib_renderproducts import RenderProducts -from openpype.client import get_last_version_by_subset_name class CollectRender(pyblish.api.InstancePlugin): @@ -27,7 +25,6 @@ class CollectRender(pyblish.api.InstancePlugin): filepath = current_file.replace("\\", "/") context.data['currentFile'] = current_file - asset = get_current_asset_name() files_by_aov = RenderProducts().get_beauty(instance.name) aovs = RenderProducts().get_aovs(instance.name) @@ -49,19 +46,6 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["files"].append(files_by_aov) img_format = RenderProducts().image_format() - project_name = context.data["projectName"] - asset_doc = context.data["assetEntity"] - asset_id = asset_doc["_id"] - version_doc = get_last_version_by_subset_name(project_name, - instance.name, - asset_id) - self.log.debug("version_doc: {0}".format(version_doc)) - version_int = 1 - if version_doc: - version_int += int(version_doc["name"]) - - self.log.debug(f"Setting {version_int} to context.") - context.data["version"] = version_int # OCIO config not support in # most of the 3dsmax renderers # so this is currently hard coded @@ -87,7 +71,7 @@ class CollectRender(pyblish.api.InstancePlugin): renderer = str(renderer_class).split(":")[0] # also need to get the render dir for conversion data = { - "asset": asset, + "asset": instance.data["asset"], "subset": str(instance.name), "publish": True, "maxversion": str(get_max_version()), @@ -99,7 +83,6 @@ class CollectRender(pyblish.api.InstancePlugin): "plugin": "3dsmax", "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], - "version": version_int, "farm": True } instance.data.update(data) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 7920c1e82b..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", From 424a0d6f2fdb9ab866aeb7bbf2c8d552e7a19a95 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 11:14:38 +0000 Subject: [PATCH 0918/1224] Fix missing grease pencils in thumbnails and playblasts --- openpype/hosts/blender/plugins/publish/collect_review.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 3bf2e39e24..2760ab9811 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -31,11 +31,12 @@ class CollectReview(pyblish.api.InstancePlugin): focal_length = cameras[0].data.lens - # get isolate objects list from meshes instance members . + # get isolate objects list from meshes instance members. + types = {"MESH", "GPENCIL"} isolate_objects = [ obj for obj in instance - if isinstance(obj, bpy.types.Object) and obj.type == "MESH" + if isinstance(obj, bpy.types.Object) and obj.type in types ] if not instance.data.get("remove"): From e4aa43e91bdc9e8f81055d7357bfb219cdd98a68 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 11:53:49 +0000 Subject: [PATCH 0919/1224] Fix Blender Render Settings in Ayon --- server_addon/blender/server/settings/main.py | 2 +- server_addon/blender/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index 4476ea709b..374b2fafa2 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -41,7 +41,7 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) - render_settings: RenderSettingsModel = Field( + RenderSettings: RenderSettingsModel = Field( default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" From 0916db5fa0c98e239e65ff5a95fa76184fec613f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 14:35:22 +0200 Subject: [PATCH 0920/1224] set f1 and f2 to $FSTART and $FEND respectively --- openpype/hosts/houdini/plugins/create/create_composite.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_composite.py b/openpype/hosts/houdini/plugins/create/create_composite.py index 9d4f7969bb..52ea6fa054 100644 --- a/openpype/hosts/houdini/plugins/create/create_composite.py +++ b/openpype/hosts/houdini/plugins/create/create_composite.py @@ -45,6 +45,11 @@ class CreateCompositeSequence(plugin.HoudiniCreator): instance_node.setParms(parms) + # Manually set f1 & f2 to $FSTART and $FEND respectively + # to match other Houdini nodes default. + instance_node.parm("f1").setExpression("$FSTART") + instance_node.parm("f2").setExpression("$FEND") + # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) From 41d9cf65b0d8d70affefe47e6feaca71c406a4d0 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:05:33 +0200 Subject: [PATCH 0921/1224] make tab menu name change according to the app whether OpenPype or AYON --- openpype/hosts/houdini/api/creator_node_shelves.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 1f9fef7417..085642a277 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -173,6 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() + tab_menu_label = os.environ.get("AVALON_LABEL") or "OpenPype" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins @@ -197,14 +198,14 @@ def install(): if not network_categories: continue - key = "openpype_create.{}".format(identifier) + key = "ayon_create.{}".format(identifier) log.debug(f"Registering {key}") script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, "icon": icon, - "help": "Create OpenPype publish instance for {}".format( + "help": "Create Ayon publish instance for {}".format( creator.label ), "help_url": None, @@ -213,7 +214,7 @@ def install(): "cop_viewer_categories": [], "network_op_type": None, "viewer_op_type": None, - "locations": ["OpenPype"] + "locations": [tab_menu_label] } label = "Create {}".format(creator.label) tool = hou.shelves.tool(key) From c577d2bc84854382ecf157b22f293d4f23c38298 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 1 Nov 2023 13:09:57 +0000 Subject: [PATCH 0922/1224] Fix default settings --- server_addon/blender/server/settings/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index 374b2fafa2..5eff276ef5 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -61,7 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, - "render_settings": DEFAULT_RENDER_SETTINGS, + "RenderSettings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, From eb242e78a5afeef40c814bc27a140dbfdc0e7f85 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:13:40 +0200 Subject: [PATCH 0923/1224] add get_network_categories --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 8 +++++++- openpype/hosts/houdini/plugins/create/create_hda.py | 7 ++++++- .../houdini/plugins/create/create_redshift_proxy.py | 12 +++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index a3f31e7e94..0f629cf9c9 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -3,6 +3,7 @@ from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError from openpype.lib import EnumDef +import hou class CreateBGEO(plugin.HoudiniCreator): @@ -13,7 +14,6 @@ class CreateBGEO(plugin.HoudiniCreator): icon = "gears" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) @@ -90,3 +90,9 @@ class CreateBGEO(plugin.HoudiniCreator): return attrs + [ EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), ] + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index c4093bfbc6..ac075d2072 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -5,6 +5,7 @@ from openpype.client import ( get_subsets, ) from openpype.hosts.houdini.api import plugin +import hou class CreateHDA(plugin.HoudiniCreator): @@ -35,7 +36,6 @@ class CreateHDA(plugin.HoudiniCreator): def create_instance_node( self, node_name, parent, node_type="geometry"): - import hou parent_node = hou.node("/obj") if self.selected_nodes: @@ -81,3 +81,8 @@ class CreateHDA(plugin.HoudiniCreator): pre_create_data) # type: plugin.CreatedInstance return instance + + def get_network_categories(self): + return [ + hou.objNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py index b814dd9d57..3a4ab7008b 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating Redshift proxies.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance +import hou class CreateRedshiftProxy(plugin.HoudiniCreator): @@ -12,7 +12,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): icon = "magic" def create(self, subset_name, instance_data, pre_create_data): - import hou # noqa + # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) @@ -28,7 +28,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): instance = super(CreateRedshiftProxy, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) instance_node = hou.node(instance.get("instance_node")) @@ -44,3 +44,9 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): # Lock some Avalon attributes to_lock = ["family", "id", "prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] From aed9b13e0048951f9cc8605415ea7d5142ca11cb Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:15:09 +0200 Subject: [PATCH 0924/1224] update ids to 'ayon' instead of 'openpype' --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index b2e32a70f9..c875cac0f5 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -16,7 +16,7 @@ return label - + - + - + Date: Wed, 1 Nov 2023 15:35:09 +0200 Subject: [PATCH 0925/1224] update get_network_categories --- openpype/hosts/houdini/plugins/create/create_pointcache.py | 1 + openpype/hosts/houdini/plugins/create/create_staticmesh.py | 1 + openpype/hosts/houdini/plugins/create/create_vbd_cache.py | 1 + 3 files changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 7eaf2aff2b..8fe8052e0a 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -83,6 +83,7 @@ class CreatePointCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py index ea0b36f03f..d0985198bd 100644 --- a/openpype/hosts/houdini/plugins/create/create_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -54,6 +54,7 @@ class CreateStaticMesh(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index 9c96e48e3a..69418f9575 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -40,6 +40,7 @@ class CreateVDBCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] From 25c8a424ec65330777bc3968c41d9fe6f59d470c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 15:40:29 +0200 Subject: [PATCH 0926/1224] skip check if node has no 'trange' parameter --- .../hosts/houdini/plugins/publish/validate_frame_range.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 6a66f3de9f..2264372549 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -57,6 +57,14 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): return rop_node = hou.node(instance.data["instance_node"]) + + if rop_node.parm("trange") is None: + cls.log.debug( + "Skipping Check, Node has no 'trange' parameter: {}" + .format(rop_node.path()) + ) + return + if instance.data["frameStart"] > instance.data["frameEnd"]: cls.log.info( "The ROP node render range is set to " From 60438ab4a8bed39a8ee681f03e995e88a8b17943 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:00:41 +0200 Subject: [PATCH 0927/1224] BigRoy's comments - Better conditional and debug message --- .../houdini/plugins/publish/validate_frame_range.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 2264372549..5d3866cfdb 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -57,15 +57,17 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): return rop_node = hou.node(instance.data["instance_node"]) + frame_start = instance.data.get("frameStart") + frame_end = instance.data.get("frameEnd") - if rop_node.parm("trange") is None: + if not (frame_start or frame_end): cls.log.debug( - "Skipping Check, Node has no 'trange' parameter: {}" - .format(rop_node.path()) + "Skipping frame range validation for " + "instance without frame data: {}".format(rop_node.path()) ) return - if instance.data["frameStart"] > instance.data["frameEnd"]: + if frame_start > frame_end: cls.log.info( "The ROP node render range is set to " "{0[frameStartHandle]} - {0[frameEndHandle]} " From 5a873368ee9b2544ea2c19401354ec5f94712537 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:56:23 +0200 Subject: [PATCH 0928/1224] fix loading bug --- openpype/hosts/houdini/plugins/load/load_image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 663a93e48b..cff2b74e52 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -119,7 +119,8 @@ class ImageLoader(load.LoaderPlugin): if not parent.children(): parent.destroy() - def _get_file_sequence(self, root): + def _get_file_sequence(self, file_path): + root = os.path.dirname(file_path) files = sorted(os.listdir(root)) first_fname = files[0] From dca872e1fce9f1735063769d17e8256f9c003125 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 17:57:00 +0200 Subject: [PATCH 0929/1224] fix collector order to fix the missing frames --- openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_frames.py | 4 +++- openpype/hosts/houdini/plugins/publish/collect_karma_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py | 4 ++-- .../hosts/houdini/plugins/publish/collect_redshift_rop.py | 4 ++-- openpype/hosts/houdini/plugins/publish/collect_vray_rop.py | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index b489f83b29..420a8324fe 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -21,8 +21,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): label = "Arnold ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 01df809d4c..79cfcc6139 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -11,7 +11,9 @@ from openpype.hosts.houdini.api import lib class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" - order = pyblish.api.CollectorOrder + 0.01 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.4999 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "bgeo"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index fe0b8711fc..a477529df9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -25,8 +25,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): label = "Karma ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index cc412f30a1..9f0ae8d33c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -25,8 +25,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): label = "Mantra ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index deb9eac971..0bd7b41641 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -25,8 +25,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): label = "Redshift ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 53072aebc6..519c12aede 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -25,8 +25,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): label = "VRay ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.49999 hosts = ["houdini"] families = ["vray_rop"] From c15adfa327cdb30f13c21f9e0f6c11d18c73ca9e Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 18:05:23 +0200 Subject: [PATCH 0930/1224] BigRoys' commit - fallback to AYON --- openpype/hosts/houdini/api/creator_node_shelves.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 085642a277..14662dc419 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -173,7 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() - tab_menu_label = os.environ.get("AVALON_LABEL") or "OpenPype" + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins From c64af8ddef70412de33b8c04ff048cfbdc41d77d Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 18:06:36 +0200 Subject: [PATCH 0931/1224] BigRoy's comment - remove Obj from network_categories to avoid possible confusion --- openpype/hosts/houdini/plugins/create/create_pointcache.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 8fe8052e0a..7eaf2aff2b 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -83,7 +83,6 @@ class CreatePointCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), - hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] From bd8638caa10524dca1554208d4b301413729983b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 1 Nov 2023 17:16:37 +0100 Subject: [PATCH 0932/1224] ftrack events are not processed if project is not available in OpenPype database --- .../event_first_version_status.py | 45 ++++++++++++++----- .../event_next_task_update.py | 6 +++ .../event_push_frame_values_to_task.py | 6 +++ .../event_task_to_parent_status.py | 6 +++ .../event_task_to_version_status.py | 6 +++ .../event_thumbnail_updates.py | 6 +++ .../event_version_to_task_statuses.py | 5 +++ 7 files changed, 70 insertions(+), 10 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py index 8ef333effd..2ac02f233e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py @@ -1,3 +1,6 @@ +import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -73,8 +76,21 @@ class FirstVersionStatus(BaseEvent): if not self.task_status_map: return - entities_info = self.filter_event_ents(event) - if not entities_info: + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return + + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) + + def process_by_project(self, session, event, project_id, entities_info): + project_name = self.get_project_name_from_event( + session, event, project_id + ) + if get_project(project_name) is None: + self.log.debug( + f"Project '{project_name}' not found in OpenPype. Skipping" + ) return entity_ids = [] @@ -154,18 +170,18 @@ class FirstVersionStatus(BaseEvent): exc_info=True ) - def filter_event_ents(self, event): - filtered_ents = [] - for entity in event["data"].get("entities", []): + def filter_entities_info(self, event): + filtered_entities_info = collections.defaultdict(list) + for entity_info in event["data"].get("entities", []): # Care only about add actions - if entity.get("action") != "add": + if entity_info.get("action") != "add": continue # Filter AssetVersions - if entity["entityType"] != "assetversion": + if entity_info["entityType"] != "assetversion": continue - entity_changes = entity.get("changes") or {} + entity_changes = entity_info.get("changes") or {} # Check if version of Asset Version is `1` version_num = entity_changes.get("version", {}).get("new") @@ -177,9 +193,18 @@ class FirstVersionStatus(BaseEvent): if not task_id: continue - filtered_ents.append(entity) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - return filtered_ents + if project_id is None: + continue + + filtered_entities_info[project_id].append(entity_info) + + return filtered_entities_info def register(session): diff --git a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py index a100c34f67..07a8ff433e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py +++ b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -99,6 +101,10 @@ class NextTaskUpdate(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index ed630ad59d..65c3c1a69a 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -3,6 +3,8 @@ import copy from typing import Any import ftrack_api + +from openpype.client import get_project from openpype_modules.ftrack.lib import ( BaseEvent, query_custom_attributes, @@ -139,6 +141,10 @@ class PushHierValuesToNonHierEvent(BaseEvent): project_name: str = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return set(), set() + # Load settings project_settings: dict[str, Any] = ( self.get_project_settings_from_event(event, project_name) diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py index 25fa3b0535..d2b395a1a3 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -60,6 +62,10 @@ class TaskStatusToParent(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py index b77849c678..91ee2410d7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -102,6 +104,10 @@ class TaskToVersionStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py index 64673f792c..318e69f414 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py +++ b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -22,6 +24,10 @@ class ThumbnailEvents(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index fb40fd6417..fbe44bcba7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -1,3 +1,4 @@ +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -50,6 +51,10 @@ class VersionToTaskStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name From d4b75797c69a59725e3d8348f44aefedb5136455 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 20:49:05 +0100 Subject: [PATCH 0933/1224] nuke: making sure duplicated loader is not removed --- openpype/hosts/nuke/api/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index c39e3c339d..301b9533a9 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -537,6 +537,7 @@ class NukeLoader(LoaderPlugin): node.addKnob(knob) def clear_members(self, parent_node): + parent_class = parent_node.Class() members = self.get_members(parent_node) dependent_nodes = None @@ -549,6 +550,8 @@ class NukeLoader(LoaderPlugin): break for member in members: + if member.Class() == parent_class: + continue self.log.info("removing node: `{}".format(member.name())) nuke.delete(member) From e3eea5a8e35fedd12bebfdc0da77338850b22457 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 20:49:36 +0100 Subject: [PATCH 0934/1224] Nuke: updating without node renameing --- openpype/hosts/nuke/plugins/load/load_clip.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 19038b168d..737da3746d 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -299,9 +299,6 @@ class LoadClip(plugin.NukeLoader): "Representation id `{}` is failing to load".format(repre_id)) return - read_name = self._get_node_name(representation) - - read_node["name"].setValue(read_name) read_node["file"].setValue(filepath) # to avoid multiple undo steps for rest of process From 83798a7b5e3daa3c71bb34d0849e70817730b311 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 22:15:41 +0200 Subject: [PATCH 0935/1224] BigRoy's comment - fallback to 'AYON' --- 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 ac375c56d6..db7f0886c3 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1018,7 +1018,7 @@ def self_publish(): def add_self_publish_button(node): """Adds a self publish button to the rop node.""" - label = os.environ.get("AVALON_LABEL") or "OpenPype" + label = os.environ.get("AVALON_LABEL") or "AYON" button_parm = hou.ButtonParmTemplate( "ayon_self_publish", From 3a78230ba4c8fc69285d9b660499891245479765 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 1 Nov 2023 22:16:57 +0200 Subject: [PATCH 0936/1224] BigRoy's comment - fallback to 'AYON' --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index c875cac0f5..0903aef7bc 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -4,7 +4,7 @@ Date: Wed, 1 Nov 2023 22:20:42 +0200 Subject: [PATCH 0937/1224] BigRoy's comment - Update COnditional --- openpype/hosts/houdini/plugins/publish/validate_frame_range.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 5d3866cfdb..90a079217b 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -60,7 +60,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): frame_start = instance.data.get("frameStart") frame_end = instance.data.get("frameEnd") - if not (frame_start or frame_end): + if frame_start is None or frame_end is None: cls.log.debug( "Skipping frame range validation for " "instance without frame data: {}".format(rop_node.path()) From 978ec89f6562c82d2cb9d878cc3563448a21aca3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 21:35:20 +0100 Subject: [PATCH 0938/1224] Nuke: updating ls method to have full name and node --- openpype/hosts/nuke/api/pipeline.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index a1d290646c..f6ba33f00f 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -478,8 +478,6 @@ def parse_container(node): """ data = read_avalon_data(node) - # (TODO) Remove key validation when `ls` has re-implemented. - # # If not all required data return the empty container required = ["schema", "id", "name", "namespace", "loader", "representation"] @@ -487,7 +485,10 @@ def parse_container(node): return # Store the node's name - data["objectName"] = node["name"].value() + data.update({ + "objectName": node.fullName(), + "node": node, + }) return data From 16aad9928823a10034d6d9bfc1ba7cb25fd24a53 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 1 Nov 2023 21:36:48 +0100 Subject: [PATCH 0939/1224] nuke: updating loaders so they are using nodes rather then objectName --- .../hosts/nuke/plugins/load/load_backdrop.py | 16 ++++---- .../nuke/plugins/load/load_camera_abc.py | 24 ++++++----- openpype/hosts/nuke/plugins/load/load_clip.py | 6 +-- .../hosts/nuke/plugins/load/load_effects.py | 30 +++++++------- .../nuke/plugins/load/load_effects_ip.py | 40 ++++++++----------- .../hosts/nuke/plugins/load/load_gizmo.py | 28 +++++++------ .../hosts/nuke/plugins/load/load_gizmo_ip.py | 28 +++++++------ .../hosts/nuke/plugins/load/load_image.py | 6 +-- .../hosts/nuke/plugins/load/load_model.py | 29 ++++++++------ .../hosts/nuke/plugins/load/load_ociolook.py | 25 +++++------- .../nuke/plugins/load/load_script_precomp.py | 7 +--- 11 files changed, 117 insertions(+), 122 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 0cbd380697..54d37da203 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -64,8 +64,7 @@ class LoadBackdropNodes(load.LoaderPlugin): data_imprint = { "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name + "colorspaceInput": colorspace } for k in add_keys: @@ -194,7 +193,7 @@ class LoadBackdropNodes(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") @@ -207,10 +206,11 @@ class LoadBackdropNodes(load.LoaderPlugin): add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -252,6 +252,6 @@ class LoadBackdropNodes(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index e245b0cb5e..898c5e4e7b 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -48,10 +48,11 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,7 +112,7 @@ class AlembicCameraLoader(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + object_name = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -124,11 +125,12 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -194,6 +196,6 @@ class AlembicCameraLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 737da3746d..3a2ec3dbee 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -189,8 +189,6 @@ class LoadClip(plugin.NukeLoader): value_ = value_.replace("\\", "/") data_imprint[key] = value_ - data_imprint["objectName"] = read_name - if add_retime and version_data.get("retime", None): data_imprint["addRetime"] = True @@ -254,7 +252,7 @@ class LoadClip(plugin.NukeLoader): is_sequence = len(representation["files"]) > 1 - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] if is_sequence: representation = self._representation_with_hash_in_frame( @@ -353,7 +351,7 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index cacc00854e..cc048372d4 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -62,11 +62,12 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -159,7 +160,7 @@ class LoadEffects(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -175,12 +176,13 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -212,7 +214,7 @@ class LoadEffects(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -346,6 +348,6 @@ class LoadEffects(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index bdf3cd6965..cdfdfef3b8 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -63,11 +63,12 @@ class LoadEffectsInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -98,7 +99,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -164,28 +165,26 @@ class LoadEffectsInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") - name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) - namespace = container['namespace'] colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -217,7 +216,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -251,11 +250,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): output = nuke.createNode("Output") output.setInput(0, pre_node) - # # try to place it under Viewer1 - # if not self.connect_active_viewer(GN): - # nuke.delete(GN) - # return - # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -365,6 +359,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 23cf4d7741..19b5cca74e 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -64,11 +64,12 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,7 +112,7 @@ class LoadGizmo(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -126,12 +127,13 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -175,6 +177,6 @@ class LoadGizmo(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index ce0a1615f1..5b4877678a 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -66,11 +66,12 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -118,7 +119,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -133,12 +134,13 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -256,6 +258,6 @@ class LoadGizmoInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 6bffb97e6f..411a61d77b 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -146,8 +146,6 @@ class LoadImage(load.LoaderPlugin): data_imprint.update( {k: context["version"]['data'].get(k, str(None))}) - data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) return containerise(r, @@ -168,7 +166,7 @@ class LoadImage(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container["objectName"]) + node = container["node"] frame_number = node["first"].value() assert node.Class() == "Read", "Must be Read" @@ -237,7 +235,7 @@ class LoadImage(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] assert node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index b9b8a0f4c0..3fe92b74d0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -46,10 +46,11 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -114,9 +115,9 @@ class AlembicModelLoader(load.LoaderPlugin): # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + # get corresponding node - model_node = nuke.toNode(object_name) + model_node = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -129,11 +130,12 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -142,7 +144,6 @@ class AlembicModelLoader(load.LoaderPlugin): file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): - model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) # collect input output dependencies @@ -163,8 +164,10 @@ class AlembicModelLoader(load.LoaderPlugin): ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) + + # paste the node back and set the position nuke.nodePaste("%clipboard%") - model_node = nuke.toNode(object_name) + model_node = nuke.selectedNode() model_node.setXYpos(xpos, ypos) # link to original input nodes diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 18c8cdba35..c0f8235253 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -55,7 +55,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): """ namespace = namespace or context['asset']['name'] suffix = secrets.token_hex(nbytes=4) - object_name = "{}_{}_{}".format( + node_name = "{}_{}_{}".format( name, namespace, suffix) # getting file path @@ -64,7 +64,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, json_f["data"]) + filepath, json_f["data"]) + # renaming group node + group_node["name"].setValue(node_name) self._node_version_color(context["version"], group_node) @@ -76,17 +78,14 @@ class LoadOcioLookNodes(load.LoaderPlugin): name=name, namespace=namespace, context=context, - loader=self.__class__.__name__, - data={ - "objectName": object_name, - } + loader=self.__class__.__name__ ) def _create_group_node( self, - object_name, filepath, - data + data, + group_node=None ): """Creates group node with all the nodes inside. @@ -94,9 +93,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): in between - in case those are needed. Arguments: - object_name (str): name of the group node filepath (str): path to json file data (dict): data from json file + group_node (Optional[nuke.Node]): group node or None Returns: nuke.Node: group node with all the nodes inside @@ -117,7 +116,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): input_node = None output_node = None - group_node = nuke.toNode(object_name) if group_node: # remove all nodes between Input and Output nodes for node in group_node.nodes(): @@ -130,7 +128,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): else: group_node = nuke.createNode( "Group", - "name {}_1".format(object_name), inpanel=False ) @@ -227,16 +224,16 @@ class LoadOcioLookNodes(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + group_node = container["node"] filepath = get_representation_path(representation) json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, - json_f["data"] + json_f["data"], + group_node ) self._node_version_color(version_doc, group_node) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index d5f9d24765..cbe19d217b 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -46,8 +46,6 @@ class LinkAsGroup(load.LoaderPlugin): file = self.filepath_from_context(context).replace("\\", "/") self.log.info("file: {}\n".format(file)) - precomp_name = context["representation"]["context"]["subset"] - self.log.info("versionData: {}\n".format(context["version"]["data"])) # add additional metadata from the version to imprint to Avalon knob @@ -62,7 +60,6 @@ class LinkAsGroup(load.LoaderPlugin): } for k in add_keys: data_imprint.update({k: context["version"]['data'][k]}) - data_imprint.update({"objectName": precomp_name}) # group context is set to precomp, so back up one level. nuke.endGroup() @@ -118,7 +115,7 @@ class LinkAsGroup(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container['objectName']) + node = container["node"] root = get_representation_path(representation).replace("\\", "/") @@ -159,6 +156,6 @@ class LinkAsGroup(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) From 8bf570ceef7823a2bf8615a30ac15dfc59c8eaf7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:24:25 +0800 Subject: [PATCH 0940/1224] up version for the max bundle --- server_addon/max/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From 881340b60a1dd2eba9d76331082679b9e26e6df9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:27:22 +0800 Subject: [PATCH 0941/1224] supports families check before the validation of loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 59 +++++++++++++------ .../plugins/publish/collect_scene_version.py | 1 + openpype/settings/ayon_settings.py | 13 ++++ .../defaults/project_settings/max.json | 2 +- .../schemas/schema_max_publish.json | 12 ++-- .../max/server/settings/publishers.py | 12 +++- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 69f72ccf1d..e8284aeedd 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- """Validator for Loaded Plugin.""" -from pyblish.api import ContextPlugin, ValidatorOrder +import os +from pyblish.api import InstancePlugin, ValidatorOrder from pymxs import runtime as rt from openpype.pipeline.publish import ( - RepairContextAction, + RepairAction, OptionalPyblishPluginMixin, PublishValidationError ) @@ -12,7 +13,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - ContextPlugin): + InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings""" @@ -21,17 +22,17 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, hosts = ["max"] label = "Validate Loaded Plugins" optional = True - actions = [RepairContextAction] + actions = [RepairAction] - def get_invalid(self, context): + def get_invalid(self, instance): """Plugin entry point.""" - if not self.is_active(context.data): + if not self.is_active(instance.data): self.log.debug("Skipping Validate Loaded Plugin...") return required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] + instance.context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["family_plugins_mapping"] ) if not required_plugins: @@ -45,9 +46,21 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, get_plugins()) } - for plugin in required_plugins: - plugin_name = plugin.lower() + for families, plugin in required_plugins.items(): + families_list = families.split(",") + excluded_families = [family for family in families_list + if instance.data["family"]!=family + and family!="_"] + if excluded_families: + self.log.debug("The {} instance is not part of {}.".format( + instance.data["family"], excluded_families + )) + return + if not plugin: + return + + plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) if plugin_index is None: @@ -61,8 +74,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, return invalid - def process(self, context): - invalid_plugins = self.get_invalid(context) + def process(self, instance): + invalid_plugins = self.get_invalid(instance) if invalid_plugins: bullet_point_invalid_statement = "\n".join( "- {}".format(invalid) for invalid in invalid_plugins @@ -76,18 +89,30 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, report, title="Required Plugins unloaded") @classmethod - def repair(cls, context): + def repair(cls, instance): # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] + instance.context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["family_plugins_mapping"] ) - for plugin in required_plugins: - plugin_name = plugin.lower() + for families, plugin in required_plugins.items(): + families_list = families.split(",") + excluded_families = [family for family in families_list + if instance.data["family"]!=family + and family!="_"] + if excluded_families: + cls.log.debug("The {} instance is not part of {}.".format( + instance.data["family"], excluded_families + )) + continue + if not plugin: + continue + + plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) if plugin_index is None: diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 7920c1e82b..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..0cc2abdda4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -640,6 +640,19 @@ def _convert_3dsmax_project_settings(ayon_settings, output): } ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + ayon_publish = ayon_max["publish"] + if "ValidateLoadedPlugin" in ayon_publish: + family_plugin_mapping = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] + ) + new_family_plugin_mapping = { + item["families"]: item["plugins"] + for item in family_plugin_mapping + } + ayon_max["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( + new_family_plugin_mapping + ) + output["max"] = ayon_max diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 45246fdf2b..78eba08750 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -40,7 +40,7 @@ "ValidateLoadedPlugin": { "enabled": false, "optional": true, - "plugins_for_check": [] + "family_plugins_mapping": {} } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index 4490c5353d..74c06f8156 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -47,10 +47,14 @@ "label": "Optional" }, { - "type": "list", - "key": "plugins_for_check", - "label": "Plugins Needed For Check", - "object_type": "text" + "type": "dict-modifiable", + "collapsible": true, + "key": "family_plugins_mapping", + "label": "Family Plugins Mapping", + "use_label_wrap": true, + "object_type": { + "type": "text" + } } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index 8a28224a07..3cf3ecf2a5 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -3,11 +3,17 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +class FamilyPluginsMappingModel(BaseSettingsModel): + _layout = "compact" + families: str = Field(title="Families") + plugins: str = Field(title="Plugins") + + class ValidateLoadedPluginModel(BaseSettingsModel): enabled: bool = Field(title="ValidateLoadedPlugin") optional: bool = Field(title="Optional") - plugins_for_check: list[str] = Field( - default_factory=list, title="Plugins Needed For Check" + family_plugins_mapping: list[FamilyPluginsMappingModel] = Field( + default_factory=list, title="Family Plugins Mapping" ) @@ -37,6 +43,6 @@ DEFAULT_PUBLISH_SETTINGS = { "ValidateLoadedPlugin": { "enabled": False, "optional": True, - "plugins_for_check": [] + "family_plugins_mapping": {} } } From 8d727a9b80922bb07b468f694ab4f57f69945926 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:30:38 +0800 Subject: [PATCH 0942/1224] hound --- .../max/plugins/publish/validate_loaded_plugin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index e8284aeedd..dc82c7ed65 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -32,7 +32,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, required_plugins = ( instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["family_plugins_mapping"] + ["ValidateLoadedPlugin"] + ["family_plugins_mapping"] ) if not required_plugins: @@ -49,8 +50,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, for families, plugin in required_plugins.items(): families_list = families.split(",") excluded_families = [family for family in families_list - if instance.data["family"]!=family - and family!="_"] + if instance.data["family"] != family + and family != "_"] if excluded_families: self.log.debug("The {} instance is not part of {}.".format( instance.data["family"], excluded_families @@ -97,13 +98,14 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, } required_plugins = ( instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["family_plugins_mapping"] + ["ValidateLoadedPlugin"] + ["family_plugins_mapping"] ) for families, plugin in required_plugins.items(): families_list = families.split(",") excluded_families = [family for family in families_list - if instance.data["family"]!=family - and family!="_"] + if instance.data["family"] != family + and family != "_"] if excluded_families: cls.log.debug("The {} instance is not part of {}.".format( instance.data["family"], excluded_families From 1aef9dc449b525313760db6e3d7e86378e6f1ab9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:45:48 +0800 Subject: [PATCH 0943/1224] make sure the validator can be loaded in AYON --- openpype/settings/ayon_settings.py | 2 +- server_addon/max/server/settings/publishers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 0cc2abdda4..4fe19c95a2 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -649,7 +649,7 @@ def _convert_3dsmax_project_settings(ayon_settings, output): item["families"]: item["plugins"] for item in family_plugin_mapping } - ayon_max["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( new_family_plugin_mapping ) diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index 3cf3ecf2a5..d0fbb3d552 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -43,6 +43,6 @@ DEFAULT_PUBLISH_SETTINGS = { "ValidateLoadedPlugin": { "enabled": False, "optional": True, - "family_plugins_mapping": {} + "family_plugins_mapping": [] } } From 3da4e1c8e7eafec01b4a501234c6a78cf7a1c686 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Nov 2023 08:33:08 +0000 Subject: [PATCH 0944/1224] Add Nuke 11.0 --- .../system_settings/applications.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 6a0ddb398e..a5283751e9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -344,13 +344,30 @@ }, "environment": {} }, + "11-0": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.0v4\\Nuke11.0.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, "__dynamic_keys_labels__": { "13-2": "13.2", "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2" + "11-2": "11.2", + "11-0": "11.0" } } }, From 58c9664f7e8ac2082a44279e652a1fb82674769d Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 2 Nov 2023 10:39:28 +0000 Subject: [PATCH 0945/1224] Use sets and don't check container children when getting unique number --- openpype/hosts/blender/api/plugin.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 45b0c60b3b..2f940011ba 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -44,15 +44,15 @@ def get_unique_number( if not avalon_container: return "01" # Check the names of both object and collection containers - obj_asset_groups = avalon_container.all_objects - obj_group_names = [ + obj_asset_groups = avalon_container.objects + obj_group_names = { c.name for c in obj_asset_groups - if c.type == 'EMPTY' and c.get(AVALON_PROPERTY)] - coll_asset_groups = avalon_container.children_recursive - coll_group_names = [ + if c.type == 'EMPTY' and c.get(AVALON_PROPERTY)} + coll_asset_groups = avalon_container.children + coll_group_names = { c.name for c in coll_asset_groups - if c.get(AVALON_PROPERTY)] - container_names = obj_group_names + coll_group_names + if c.get(AVALON_PROPERTY)} + container_names = obj_group_names.union(coll_group_names) count = 1 name = f"{asset}_{count:0>2}_{subset}" while name in container_names: From 59bb86dc3337cb340d3ed9bf1c842167f98fa6b0 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 2 Nov 2023 11:42:39 +0100 Subject: [PATCH 0946/1224] Rename BlenderCreator into BaseCreator --- openpype/hosts/blender/api/plugin.py | 2 +- openpype/hosts/blender/plugins/create/create_action.py | 2 +- openpype/hosts/blender/plugins/create/create_animation.py | 2 +- openpype/hosts/blender/plugins/create/create_camera.py | 2 +- openpype/hosts/blender/plugins/create/create_layout.py | 2 +- openpype/hosts/blender/plugins/create/create_model.py | 2 +- openpype/hosts/blender/plugins/create/create_pointcache.py | 2 +- openpype/hosts/blender/plugins/create/create_render.py | 2 +- openpype/hosts/blender/plugins/create/create_review.py | 2 +- openpype/hosts/blender/plugins/create/create_rig.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index dbd9f25d68..3ddc375670 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -140,7 +140,7 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class BlenderCreator(Creator): +class BaseCreator(Creator): """Base class for Blender Creator plug-ins.""" defaults = ['Main'] diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index e7b689c54e..7d00aa1dcb 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -8,7 +8,7 @@ from openpype.hosts.blender.api import lib from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class CreateAction(openpype.hosts.blender.api.plugin.BlenderCreator): +class CreateAction(openpype.hosts.blender.api.plugin.BaseCreator): """Action output for character rigs""" identifier = "io.openpype.creators.blender.action" diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 8b4214ceda..6cfd054e74 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateAnimation(plugin.BlenderCreator): +class CreateAnimation(plugin.BaseCreator): """Animation output for character rigs""" identifier = "io.openpype.creators.blender.animation" diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 4747e50b2e..5d9682e575 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateCamera(plugin.BlenderCreator): +class CreateCamera(plugin.BaseCreator): """Polygonal static geometry""" identifier = "io.openpype.creators.blender.camera" diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 0c97d57af3..ed47b0632f 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateLayout(plugin.BlenderCreator): +class CreateLayout(plugin.BaseCreator): """Layout output for character rigs""" identifier = "io.openpype.creators.blender.layout" diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 761d9fca9f..949fae0f76 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateModel(plugin.BlenderCreator): +class CreateModel(plugin.BaseCreator): """Polygonal static geometry""" identifier = "io.openpype.creators.blender.model" diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index a40bd5af61..2ad12caa9c 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -8,7 +8,7 @@ from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class CreatePointcache(plugin.BlenderCreator): +class CreatePointcache(plugin.BaseCreator): """Polygonal static geometry""" identifier = "io.openpype.creators.blender.pointcache" diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index ab3119b32e..45570f3491 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateRenderlayer(plugin.BlenderCreator): +class CreateRenderlayer(plugin.BaseCreator): """Single baked camera""" name = "renderingMain" diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 8c9a8d5927..e8b893b4c0 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateReview(plugin.BlenderCreator): +class CreateReview(plugin.BaseCreator): """Single baked camera""" identifier = "io.openpype.creators.blender.review" diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 110a9f5c8e..6223e64174 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -10,7 +10,7 @@ from openpype.hosts.blender.api.pipeline import ( ) -class CreateRig(plugin.BlenderCreator): +class CreateRig(plugin.BaseCreator): """Artist-friendly rig with controls to direct motion""" identifier = "io.openpype.creators.blender.rig" From 7a1099b57e351e47d57e500c9184d2183527e61d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:08:49 +0100 Subject: [PATCH 0947/1224] fix access to bundles in dev mode --- openpype/settings/ayon_settings.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..5b179158f0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1436,7 +1436,10 @@ class _AyonSettingsCache: def _use_bundles(cls): if _AyonSettingsCache.use_bundles is None: major, minor, _, _, _ = ayon_api.get_server_version_tuple() - _AyonSettingsCache.use_bundles = major == 0 and minor >= 3 + use_bundles = True + if (major, minor) < (0, 3): + use_bundles = False + _AyonSettingsCache.use_bundles = use_bundles return _AyonSettingsCache.use_bundles @classmethod @@ -1467,7 +1470,7 @@ class _AyonSettingsCache: bundles = ayon_api.get_bundles() user = ayon_api.get_user() username = user["name"] - for bundle in bundles: + for bundle in bundles["bundles"]: if ( bundle.get("isDev") and bundle.get("activeUser") == username From 13ec4d9a537296e648a4cf33ab5e9da865e78145 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:31:58 +0100 Subject: [PATCH 0948/1224] fix formatting order --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index e8b85d0e93..15bde39f68 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -106,7 +106,7 @@ class _ModuleClass(object): if attr_name in self.__attributes__: self.log.warning( "Duplicated name \"{}\" in {}. Overriding.".format( - self.name, attr_name + attr_name, self.name ) ) self.__attributes__[attr_name] = value From 36f928151dc1e9a9996654ff8d698b7f1b51058a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:32:18 +0100 Subject: [PATCH 0949/1224] safe call of get plugins path --- openpype/modules/base.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 15bde39f68..f47baa0e4d 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -997,7 +997,17 @@ class ModulesManager: continue method = getattr(module, method_name) - paths = method(*args, **kwargs) + try: + paths = method(*args, **kwargs) + except Exception: + self.log.warning( + "Failed to get plugin paths from module {}.".format( + module.__class__.__name__ + ), + exc_info=True + ) + continue + if paths: # Convert to list if value is not list if not isinstance(paths, (list, tuple, set)): From f912c2c69c9743be16705781d4a388c355ecf68c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:32:35 +0100 Subject: [PATCH 0950/1224] change if conditions order --- openpype/modules/base.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index f47baa0e4d..1a3280a6e5 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -467,19 +467,19 @@ def _load_ayon_addons(openpype_modules, modules_key, log): )) continue - if len(imported_modules) == 1: - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - v3_addons_to_skip.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) + if len(imported_modules) > 1: + log.info("More then one module '{}' was imported.".format(name)) + continue - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) + mod = imported_modules[0] + addon_alias = getattr(mod, "V3_ALIAS", None) + if not addon_alias: + addon_alias = addon_name + v3_addons_to_skip.append(addon_alias) + new_import_str = "{}.{}".format(modules_key, addon_alias) - else: - log.info("More then one module was imported") + sys.modules[new_import_str] = mod + setattr(openpype_modules, addon_alias, mod) return v3_addons_to_skip From d55ac7aff7cd7d470ddac5aa9d8f9d2b43cf2cd2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:23 +0100 Subject: [PATCH 0951/1224] removed unused import --- openpype/hosts/tvpaint/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 58fbd09545..a84f196f09 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -7,7 +7,7 @@ import requests import pyblish.api -from openpype.client import get_project, get_asset_by_name +from openpype.client import get_asset_by_name from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings From 9d77421d9de2b2a7cee1d8c9e32290a632f6ac80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:36 +0100 Subject: [PATCH 0952/1224] use AYON label when in AYON mode --- openpype/hosts/tvpaint/api/communication_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index d67ef8f798..34302eef6e 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,6 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError +from openpype import AYON_SERVER_ENABLED from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path @@ -834,8 +835,9 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): + title = "AYON Tools" if AYON_SERVER_ENABLED else "OpenPype Tools" menu_definitions = { - "title": "OpenPype Tools", + "title": title, "menu_items": [ { "callback": "workfiles_tool", From 0cc90ceb081a5988192a993374b488ea0051618f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 12:33:46 +0100 Subject: [PATCH 0953/1224] removed unused 'previous_context' data --- openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 95a5cd77bd..56b51c812a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -69,7 +69,6 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "asset_name": context.data["asset"], "task_name": context.data["task"] } - context.data["previous_context"] = current_context self.log.debug("Current context is: {}".format(current_context)) # Collect context from workfile metadata From add4a1566d9dde015ac9b4422c0497f1c8710ab0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:40:41 +0100 Subject: [PATCH 0954/1224] Use 'AVALON_LABEL' for label --- openpype/hosts/tvpaint/api/communication_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 34302eef6e..2c4d8160a6 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -835,7 +835,10 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): - title = "AYON Tools" if AYON_SERVER_ENABLED else "OpenPype Tools" + label = os.getenv("AVALON_LABEL") + if not label: + label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" + title = "{} Tools".format(label) menu_definitions = { "title": title, "menu_items": [ From e9699d2cef653a00b185ed04d7874c458ae18f94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:43:27 +0100 Subject: [PATCH 0955/1224] fix grammar and use warning Co-authored-by: Roy Nieterau --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 1a3280a6e5..2d3f0d4bc1 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -468,7 +468,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue if len(imported_modules) > 1: - log.info("More then one module '{}' was imported.".format(name)) + log.warning("More than one module '{}' was imported.".format(name)) continue mod = imported_modules[0] From 88116be4c63079598ec8a38b45ad3cc329155a87 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 20:44:21 +0800 Subject: [PATCH 0956/1224] allows users to preset the settings before the creator setting --- .../hosts/max/plugins/create/create_review.py | 58 ++++++++--- openpype/plugins/publish/extract_review.py | 2 +- .../defaults/project_settings/max.json | 10 ++ .../projects_schema/schema_project_max.json | 98 +++++++++++++++++++ 4 files changed, 154 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 8052b74f06..67dc158001 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -12,6 +12,32 @@ class CreateReview(plugin.MaxCreator): family = "review" icon = "video-camera" + review_width = 1920 + review_height = 1080 + percentSize = 100 + keep_images = False + image_format = "png" + visual_style = "Realistic" + viewport_preset = "Quality" + vp_texture = True + anti_aliasing = None + + + @classmethod + def apply_settings(cls, project_settings): + settings = project_settings["max"]["PreviewAnimation"] # noqa + + # Take some defaults from settings + cls.review_width = settings.get("review_width", cls.review_width) + cls.review_height = settings.get("review_height", cls.review_height) + cls.percentSize = settings.get("percentSize", cls.percentSize) + cls.keep_images = settings.get("keep_images", cls.keep_images) + cls.image_format = settings.get("image_format", cls.image_format) + cls.visual_style = settings.get("visual_style", cls.visual_style) + cls.viewport_preset = settings.get("viewport_preset", cls.viewport_preset) + cls.vp_texture = settings.get("vp_texture", cls.vp_texture) + cls.anti_aliasing = settings.get("anti_aliasing", cls.anti_aliasing) + def create(self, subset_name, instance_data, pre_create_data): # Transfer settings from pre create to instance creator_attributes = instance_data.setdefault( @@ -23,6 +49,7 @@ class CreateReview(plugin.MaxCreator): "percentSize", "visualStyleMode", "viewportPreset", + "anti_aliasing", "vpTexture"]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] @@ -33,7 +60,7 @@ class CreateReview(plugin.MaxCreator): pre_create_data) def get_instance_attr_defs(self): - image_format_enum = ["exr", "jpg", "png"] + image_format_enum = ["exr", "jpg", "png", "tga"] visual_style_preset_enum = [ "Realistic", "Shaded", "Facets", @@ -45,41 +72,46 @@ class CreateReview(plugin.MaxCreator): preview_preset_enum = [ "Quality", "Standard", "Performance", "DXMode", "Customize"] + anti_aliasing_enum = ["None", "2X", "4X", "8X"] return [ NumberDef("review_width", label="Review width", decimals=0, minimum=0, - default=1920), + default=self.review_width), NumberDef("review_height", label="Review height", decimals=0, minimum=0, - default=1080), - BoolDef("keepImages", - label="Keep Image Sequences", - default=False), - EnumDef("imageFormat", - image_format_enum, - default="png", - label="Image Format Options"), + default=self.review_height), NumberDef("percentSize", label="Percent of Output", default=100, minimum=1, decimals=0), + BoolDef("keepImages", + label="Keep Image Sequences", + default=self.keep_images), + EnumDef("imageFormat", + image_format_enum, + default=self.image_format, + label="Image Format Options"), EnumDef("visualStyleMode", visual_style_preset_enum, - default="Realistic", + default=self.visual_style, label="Preference"), EnumDef("viewportPreset", preview_preset_enum, - default="Quality", + default=self.viewport_preset, label="Pre-View Preset"), + EnumDef("anti_aliasing", + anti_aliasing_enum, + default=self.anti_aliasing, + label="Anti-aliasing Quality"), BoolDef("vpTexture", label="Viewport Texture", - default=False) + default=self.vp_texture) ] def get_pre_create_attr_defs(self): diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 0ae941511c..db8a030dfa 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -68,7 +68,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..c610a963d4 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -16,6 +16,16 @@ "image_format": "exr", "multipass": true }, + "PreviewAnimation": { + "review_width": 1920, + "review_height": 1080, + "percentSize": 100, + "keep_images": false, + "image_format": "png", + "visual_style": "Realistic", + "viewport_preset": "Quality", + "vp_texture": true + }, "PointCloud": { "attribute": { "Age": "age", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json index e314174dff..b012e73fc4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -65,6 +65,104 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "PreviewAnimation", + "label": "Preview Animation", + "children": [ + { + "type": "number", + "key": "review_width", + "label": "Review Width" + }, + { + "type": "number", + "key": "review_height", + "label": "Review Height" + }, + { + "type": "number", + "key": "percentSize", + "label": "Percent of Output" + }, + { + "type": "boolean", + "key": "keep_images", + "label": "Keep Image Sequences" + }, + { + "key": "image_format", + "label": "Image Format Options", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "exr"}, + {"jpg": "jpg"}, + {"png": "png"}, + {"tga": "tga"} + ] + }, + { + "key": "visual_style", + "label": "Preference", + "type": "enum", + "multiselection": false, + "defaults": "Realistic", + "enum_items": [ + {"Realistic": "Realistic"}, + {"Shaded": "Shaded"}, + {"Facets": "Facets"}, + {"ConsistentColors": "ConsistentColors"}, + {"HiddenLine": "HiddenLine"}, + {"Wireframe": "Wireframe"}, + {"BoundingBox": "BoundingBox"}, + {"Ink": "Ink"}, + {"ColorInk": "ColorInk"}, + {"Acrylic": "Acrylic"}, + {"Tech": "Tech"}, + {"Graphite": "Graphite"}, + {"ColorPencil": "ColorPencil"}, + {"Pastel": "Pastel"}, + {"Clay": "Clay"}, + {"ModelAssist": "ModelAssist"} + ] + }, + { + "key": "viewport_preset", + "label": "Pre-View Preset", + "type": "enum", + "multiselection": false, + "defaults": "Quality", + "enum_items": [ + {"Quality": "Quality"}, + {"Standard": "Standard"}, + {"Performance": "Performance"}, + {"DXMode": "DXMode"}, + {"Customize": "Customize"} + ] + }, + { + "key": "anti_aliasing", + "label": "Anti-aliasing Quality", + "type": "enum", + "multiselection": false, + "defaults": "None", + "enum_items": [ + {"None": "None"}, + {"2X": "2X"}, + {"4X": "4X"}, + {"8X": "8X"} + ] + }, + { + "type": "boolean", + "key": "vp_texture", + "label": "Viewport Texture" + } + ] + }, { "type": "dict", "collapsible": true, From 3428ec08a5592e54586ea8f0126e4907a8f4eeee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 13:46:05 +0100 Subject: [PATCH 0957/1224] log which method was used to get plugins --- openpype/modules/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 2d3f0d4bc1..03ec3d271a 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -1001,9 +1001,10 @@ class ModulesManager: paths = method(*args, **kwargs) except Exception: self.log.warning( - "Failed to get plugin paths from module {}.".format( - module.__class__.__name__ - ), + ( + "Failed to get plugin paths from module" + " '{}' using '{}'." + ).format(module.__class__.__name__, method_name), exc_info=True ) continue From 597260ad520393f2942b5685a817210d18544f45 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 20:49:40 +0800 Subject: [PATCH 0958/1224] cosmetic fix --- openpype/settings/defaults/project_settings/max.json | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index c610a963d4..ac04c60b54 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -24,6 +24,7 @@ "image_format": "png", "visual_style": "Realistic", "viewport_preset": "Quality", + "anti_aliasing": "None", "vp_texture": true }, "PointCloud": { From 026aae1d0d9ebc6409acedc545c9d20950b5365a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 21:35:18 +0800 Subject: [PATCH 0959/1224] add AA quality setting options & add the correct aspect ratio --- openpype/hosts/max/api/preview_animation.py | 4 +++- openpype/hosts/max/plugins/create/create_review.py | 8 +++++++- openpype/hosts/max/plugins/publish/collect_review.py | 1 + openpype/plugins/publish/extract_review.py | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index dcf243d31e..5f36b12edb 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -189,7 +189,8 @@ def _render_preview_animation_max_pre_2024( dib = rt.gw.getViewportDib() dib_width = float(dib.width) dib_height = float(dib.height) - renderRatio = float(dib_width / dib_height) + # aspect ratio + renderRatio = rt.getRendImageAspect() if viewportRatio <= renderRatio: heightCrop = (dib_width / renderRatio) topEdge = int((dib_height - heightCrop) / 2.0) @@ -311,6 +312,7 @@ def viewport_options_for_preview_animation(): viewport_options["nitrous_viewport"] = { "VisualStyleMode": "defaultshading", "ViewportPreset": "highquality", + "AntialiasingQuality": "None", "UseTextureEnabled": False } viewport_options["vp_btn_mgr"] = { diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 8052b74f06..331d2f30ea 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -23,6 +23,7 @@ class CreateReview(plugin.MaxCreator): "percentSize", "visualStyleMode", "viewportPreset", + "antialiasingQuality", "vpTexture"]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] @@ -33,7 +34,7 @@ class CreateReview(plugin.MaxCreator): pre_create_data) def get_instance_attr_defs(self): - image_format_enum = ["exr", "jpg", "png"] + image_format_enum = ["exr", "jpg", "png", "tga"] visual_style_preset_enum = [ "Realistic", "Shaded", "Facets", @@ -45,6 +46,7 @@ class CreateReview(plugin.MaxCreator): preview_preset_enum = [ "Quality", "Standard", "Performance", "DXMode", "Customize"] + anti_aliasing_enum = ["None", "2X", "4X", "8X"] return [ NumberDef("review_width", @@ -77,6 +79,10 @@ class CreateReview(plugin.MaxCreator): preview_preset_enum, default="Quality", label="Pre-View Preset"), + EnumDef("antialiasingQuality", + anti_aliasing_enum, + default="None", + label="Anti-aliasing Quality"), BoolDef("vpTexture", label="Viewport Texture", default=False) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index b1d9c2d25e..a579b3f4b0 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -93,6 +93,7 @@ class CollectReview(pyblish.api.InstancePlugin, nitrous_viewport = { "VisualStyleMode": creator_attrs["visualStyleMode"], "ViewportPreset": creator_attrs["viewportPreset"], + "AntialiasingQuality": creator_attrs["antialiasingQuality"], "UseTextureEnabled": creator_attrs["vpTexture"] } preview_data = { diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 0ae941511c..db8a030dfa 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -68,7 +68,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts From 2d940227b1144e67c0dc37384575aea067a37d25 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:54:29 +0100 Subject: [PATCH 0960/1224] skip openpype addon --- openpype/modules/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 03ec3d271a..aa3deff475 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -408,6 +408,10 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_name = addon_info["name"] addon_version = addon_info["version"] + # OpenPype addon does not have any addon object + if addon_name == "openpype": + continue + dev_addon_info = dev_addons_info.get(addon_name, {}) use_dev_path = dev_addon_info.get("enabled", False) From 27dd549c7dae21960f38e548598da4d884b61cd8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:54:40 +0100 Subject: [PATCH 0961/1224] ignore pycahce folders --- openpype/modules/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index aa3deff475..df7286e7b7 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -442,7 +442,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon # Ignore start and setup scripts - if name in ("setup.py", "start.py"): + if name in ("setup.py", "start.py", "__pycache__"): continue path = os.path.join(addon_dir, name) From 87c3682d61392b1828e6b4c09060a77400f14b00 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:55:06 +0100 Subject: [PATCH 0962/1224] imported modules must have Module class --- openpype/modules/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index df7286e7b7..eb6e7d6b73 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -458,7 +458,15 @@ def _load_ayon_addons(openpype_modules, modules_key, log): try: mod = __import__(basename, fromlist=("",)) - imported_modules.append(mod) + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + inspect.isclass(attr) + and issubclass(attr, OpenPypeModule) + ): + imported_modules.append(mod) + break + except BaseException: log.warning( "Failed to import \"{}\"".format(basename), From db26cdd6e35a08d0025dcf8a54024b17af484099 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 21:55:22 +0800 Subject: [PATCH 0963/1224] hound --- openpype/hosts/max/plugins/create/create_review.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 67dc158001..ed0359ebd7 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -22,7 +22,6 @@ class CreateReview(plugin.MaxCreator): vp_texture = True anti_aliasing = None - @classmethod def apply_settings(cls, project_settings): settings = project_settings["max"]["PreviewAnimation"] # noqa @@ -34,9 +33,11 @@ class CreateReview(plugin.MaxCreator): cls.keep_images = settings.get("keep_images", cls.keep_images) cls.image_format = settings.get("image_format", cls.image_format) cls.visual_style = settings.get("visual_style", cls.visual_style) - cls.viewport_preset = settings.get("viewport_preset", cls.viewport_preset) + cls.viewport_preset = settings.get( + "viewport_preset", cls.viewport_preset) cls.vp_texture = settings.get("vp_texture", cls.vp_texture) - cls.anti_aliasing = settings.get("anti_aliasing", cls.anti_aliasing) + cls.anti_aliasing = settings.get( + "anti_aliasing", cls.anti_aliasing) def create(self, subset_name, instance_data, pre_create_data): # Transfer settings from pre create to instance @@ -49,7 +50,7 @@ class CreateReview(plugin.MaxCreator): "percentSize", "visualStyleMode", "viewportPreset", - "anti_aliasing", + "antialiasingQuality", "vpTexture"]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] @@ -105,7 +106,7 @@ class CreateReview(plugin.MaxCreator): preview_preset_enum, default=self.viewport_preset, label="Pre-View Preset"), - EnumDef("anti_aliasing", + EnumDef("antialiasingQuality", anti_aliasing_enum, default=self.anti_aliasing, label="Anti-aliasing Quality"), From 541d333ab8f2ee0b92bab36d96b8c07e7a2457dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 2 Nov 2023 14:56:33 +0100 Subject: [PATCH 0964/1224] more specific message when loaded multiple modules in addon dir --- openpype/modules/base.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index eb6e7d6b73..457e29905d 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -480,7 +480,14 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue if len(imported_modules) > 1: - log.warning("More than one module '{}' was imported.".format(name)) + log.warning(( + "Skipping addon '{}'." + " Multiple modules were found ({}) in dir {}." + ).format( + addon_name, + ", ".join([m.__name__ for m in imported_modules]), + addon_dir, + )) continue mod = imported_modules[0] From 487d8dfde72a9af0b2964a15d4b57a07954ce767 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 22:34:20 +0800 Subject: [PATCH 0965/1224] make sure the code doesn't break the extractor --- openpype/hosts/max/api/preview_animation.py | 14 +++++++++++++- .../hosts/max/plugins/publish/collect_review.py | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 5f36b12edb..0754fa61c4 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -52,6 +52,7 @@ def viewport_layout_and_camera(camera, layout="layout_1"): @contextlib.contextmanager def viewport_preference_setting(general_viewport, + nitrous_manager, nitrous_viewport, vp_button_mgr): """Function to set viewport setting during context @@ -59,6 +60,7 @@ def viewport_preference_setting(general_viewport, Args: camera (str): Viewport camera for review render general_viewport (dict): General viewport setting + nitrous_manager (dict): Nitrous graphic manager nitrous_viewport (dict): Nitrous setting for preview animation vp_button_mgr (dict): Viewport button manager Setting @@ -72,6 +74,9 @@ def viewport_preference_setting(general_viewport, vp_button_mgr_original = { key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr } + nitrous_manager_original = { + key: getattr(nitrousGraphicMgr, key) for key in nitrous_manager + } nitrous_viewport_original = { key: getattr(viewport_setting, key) for key in nitrous_viewport } @@ -81,6 +86,8 @@ def viewport_preference_setting(general_viewport, rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) for key, value in vp_button_mgr.items(): setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_manager.items(): + setattr(nitrousGraphicMgr, key, value) for key, value in nitrous_viewport.items(): if nitrous_viewport[key] != nitrous_viewport_original[key]: setattr(viewport_setting, key, value) @@ -91,6 +98,8 @@ def viewport_preference_setting(general_viewport, rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) for key, value in vp_button_mgr_original.items(): setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_manager_original.items(): + setattr(nitrousGraphicMgr, key, value) for key, value in nitrous_viewport_original.items(): setattr(viewport_setting, key, value) @@ -258,6 +267,7 @@ def render_preview_animation( if int(get_max_version()) < 2024: with viewport_preference_setting( viewport_options["general_viewport"], + viewport_options["nitrous_manager"], viewport_options["nitrous_viewport"], viewport_options["vp_btn_mgr"] ): @@ -309,10 +319,12 @@ def viewport_options_for_preview_animation(): "dspBkg": True, "dspGrid": False } + viewport_options["nitrous_manager"] = { + "AntialiasingQuality": "None" + } viewport_options["nitrous_viewport"] = { "VisualStyleMode": "defaultshading", "ViewportPreset": "highquality", - "AntialiasingQuality": "None", "UseTextureEnabled": False } viewport_options["vp_btn_mgr"] = { diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index a579b3f4b0..e7e957e6f1 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -90,14 +90,17 @@ class CollectReview(pyblish.api.InstancePlugin, "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid") } + nitrous_manager = { + "AntialiasingQuality": creator_attrs["antialiasingQuality"], + } nitrous_viewport = { "VisualStyleMode": creator_attrs["visualStyleMode"], "ViewportPreset": creator_attrs["viewportPreset"], - "AntialiasingQuality": creator_attrs["antialiasingQuality"], "UseTextureEnabled": creator_attrs["vpTexture"] } preview_data = { "general_viewport": general_viewport, + "nitrous_manager": nitrous_manager, "nitrous_viewport": nitrous_viewport, "vp_btn_mgr": {"EnableButtons": False} } From ae536f2409cf9b622b617150dbd9778b6c180e91 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 2 Nov 2023 16:15:03 +0100 Subject: [PATCH 0966/1224] traypublisher: folder path implementation --- openpype/hosts/traypublisher/api/editorial.py | 6 +++++- .../hosts/traypublisher/plugins/create/create_editorial.py | 4 +++- .../traypublisher/plugins/publish/collect_shot_instances.py | 1 - 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index e8f76bd314..2f5e709ffc 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -319,8 +319,12 @@ class ShotMetadataSolver: tasks = self._generate_tasks_from_settings( project_doc) + # generate hierarchy path from parents + hierarchy_path = self._create_hierarchy_path(parents) + return shot_name, { - "hierarchy": self._create_hierarchy_path(parents), + "hierarchy": hierarchy_path, + "folderPath": f"{hierarchy_path}/{shot_name}", "parents": parents, "tasks": tasks } diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 23cf066362..5dc3893697 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -217,9 +217,10 @@ or updating already created. Publishing will create OTIO file. } # Create otio editorial instance if AYON_SERVER_ENABLED: - asset_name = instance_data["folderPath"] + asset_name = instance_data.pop("folderPath") else: asset_name = instance_data["asset"] + asset_doc = get_asset_by_name(self.project_name, asset_name) if pre_create_data["fps"] == "from_selection": @@ -682,6 +683,7 @@ or updating already created. Publishing will create OTIO file. # create creator attributes creator_attributes = { "asset_name": shot_name, + "Folder path": shot_metadata["folderPath"], "Parent hierarchy path": shot_metadata["hierarchy"], "workfile_start_frame": workfile_start_frame, "fps": fps, diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py index 78c1f14e4e..b08397caf7 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py @@ -120,7 +120,6 @@ class CollectShotInstance(pyblish.api.InstancePlugin): frame_dur = frame_end - frame_start return { - "asset": _cr_attrs["asset_name"], "fps": float(_cr_attrs["fps"]), "handleStart": _cr_attrs["handle_start"], "handleEnd": _cr_attrs["handle_end"], From 976ff308e97abed1efd6b87f09c477ad16c8f329 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 2 Nov 2023 16:18:51 +0100 Subject: [PATCH 0967/1224] Add Create workfile plugin --- .../blender/plugins/create/create_workfile.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 openpype/hosts/blender/plugins/create/create_workfile.py diff --git a/openpype/hosts/blender/plugins/create/create_workfile.py b/openpype/hosts/blender/plugins/create/create_workfile.py new file mode 100644 index 0000000000..d1529f75f6 --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_workfile.py @@ -0,0 +1,100 @@ +import bpy + +from openpype.pipeline import CreatedInstance, AutoCreator +from openpype.client import get_asset_by_name +from openpype.hosts.blender.api.plugin import BaseCreator +from openpype.hosts.blender.api.lib import imprint +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY + + +class CreateWorkfile(BaseCreator, AutoCreator): + """Workfile auto-creator.""" + identifier = "io.openpype.creators.blender.workfile" + label = "Workfile" + family = "workfile" + icon = "fa5.file" + + def create(self): + """Create workfile instances.""" + current_instance = next( + ( + instance for instance in self.create_context.instances + if instance.creator_identifier == self.identifier + ), + None, + ) + + project_name = self.project_name + asset_name = self.create_context.get_current_asset_name() + task_name = self.create_context.get_current_task_name() + host_name = self.create_context.host_name + + if not current_instance: + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + task_name, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": task_name, + } + data.update( + self.get_dynamic_data( + task_name, + task_name, + asset_doc, + project_name, + host_name, + current_instance, + ) + ) + self.log.info("Auto-creating workfile instance...") + current_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(current_instance) + elif ( + current_instance["asset"] != asset_name + or current_instance["task"] != task_name + ): + # Update instance context if it's different + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + task_name, task_name, asset_doc, project_name, host_name + ) + + current_instance.update( + { + "asset": asset_name, + "task": task_name, + "subset": subset_name, + } + ) + + def collect_instances(self): + """Collect workfile instances.""" + self.cache_subsets(self.collection_shared_data) + cached_subsets = self.collection_shared_data["blender_cached_subsets"] + for node in cached_subsets.get(self.identifier, []): + created_instance = CreatedInstance.from_existing( + self.read_instance_node(node), self + ) + self._add_instance_to_context(created_instance) + + def update_instances(self, update_list): + """Update workfile instances.""" + for created_inst, _changes in update_list: + data = created_inst.data_to_store() + node = data.get("instance_node") + if not node: + task_name = self.create_context.get_current_task_name() + + bpy.context.scene[AVALON_PROPERTY] = node = { + "name": f"workfile{task_name}" + } + + created_inst["instance_node"] = node + data = created_inst.data_to_store() + + imprint(node, data) From 6a619d023b4f4bac7d48883bd16081193950d950 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 3 Nov 2023 00:53:37 +0100 Subject: [PATCH 0968/1224] Remove on instance toggled callback which isn't relevant to the new publisher --- openpype/hosts/houdini/api/pipeline.py | 56 ------------------- .../publish/collect_instances_usd_layered.py | 4 -- 2 files changed, 60 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index f8db45c56b..11135e20b2 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -3,7 +3,6 @@ import os import sys import logging -import contextlib import hou # noqa @@ -66,10 +65,6 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_event_callback("open", on_open) register_event_callback("new", on_new) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - self._has_been_setup = True # add houdini vendor packages hou_pythonpath = os.path.join(HOUDINI_HOST_DIR, "vendor") @@ -406,54 +401,3 @@ def _set_context_settings(): lib.reset_framerange() lib.update_houdini_vars_context() - - -def on_pyblish_instance_toggled(instance, new_value, old_value): - """Toggle saver tool passthrough states on instance toggles.""" - @contextlib.contextmanager - def main_take(no_update=True): - """Enter root take during context""" - original_take = hou.takes.currentTake() - original_update_mode = hou.updateModeSetting() - root = hou.takes.rootTake() - has_changed = False - try: - if original_take != root: - has_changed = True - if no_update: - hou.setUpdateMode(hou.updateMode.Manual) - hou.takes.setCurrentTake(root) - yield - finally: - if has_changed: - if no_update: - hou.setUpdateMode(original_update_mode) - hou.takes.setCurrentTake(original_take) - - if not instance.data.get("_allowToggleBypass", True): - return - - nodes = instance[:] - if not nodes: - return - - # Assume instance node is first node - instance_node = nodes[0] - - if not hasattr(instance_node, "isBypassed"): - # Likely not a node that can actually be bypassed - log.debug("Can't bypass node: %s", instance_node.path()) - return - - if instance_node.isBypassed() != (not old_value): - print("%s old bypass state didn't match old instance state, " - "updating anyway.." % instance_node.path()) - - try: - # Go into the main take, because when in another take changing - # the bypass state of a note cannot be done due to it being locked - # by default. - with main_take(no_update=True): - instance_node.bypass(not new_value) - except hou.PermissionError as exc: - log.warning("%s - %s", instance_node.path(), exc) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py index 0600730d00..d154cdc7c0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py @@ -122,10 +122,6 @@ class CollectInstancesUsdLayered(pyblish.api.ContextPlugin): instance.data.update(save_data) instance.data["usdLayer"] = layer - # Don't allow the Pyblish `instanceToggled` we have installed - # to set this node to bypass. - instance.data["_allowToggleBypass"] = False - instances.append(instance) # Store the collected ROP node dependencies From 1e4005f4454efa4ea78f3bb14c0f96de400b8734 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 16:01:44 +0800 Subject: [PATCH 0969/1224] add AYON settings support and finalize the settings --- .../hosts/max/plugins/create/create_review.py | 2 +- server_addon/max/server/settings/main.py | 8 ++ .../max/server/settings/preview_animation.py | 92 +++++++++++++++++++ server_addon/max/server/version.py | 2 +- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 server_addon/max/server/settings/preview_animation.py diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index ed0359ebd7..7aeea39b64 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -35,9 +35,9 @@ class CreateReview(plugin.MaxCreator): cls.visual_style = settings.get("visual_style", cls.visual_style) cls.viewport_preset = settings.get( "viewport_preset", cls.viewport_preset) - cls.vp_texture = settings.get("vp_texture", cls.vp_texture) cls.anti_aliasing = settings.get( "anti_aliasing", cls.anti_aliasing) + cls.vp_texture = settings.get("vp_texture", cls.vp_texture) def create(self, subset_name, instance_data, pre_create_data): # Transfer settings from pre create to instance diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py index 7f4561cbb1..0280fcebb9 100644 --- a/server_addon/max/server/settings/main.py +++ b/server_addon/max/server/settings/main.py @@ -4,6 +4,9 @@ from .imageio import ImageIOSettings from .render_settings import ( RenderSettingsModel, DEFAULT_RENDER_SETTINGS ) +from .preview_animation import ( + PreviewAnimationModel, DEFAULT_PREVIEW_ANIMATION_SETTINGS +) from .publishers import ( PublishersModel, DEFAULT_PUBLISH_SETTINGS ) @@ -29,6 +32,10 @@ class MaxSettings(BaseSettingsModel): default_factory=RenderSettingsModel, title="Render Settings" ) + PreviewAnimation: PreviewAnimationModel = Field( + default_factory=PreviewAnimationModel, + title="Preview Animation" + ) PointCloud: PointCloudSettings = Field( default_factory=PointCloudSettings, title="Point Cloud" @@ -40,6 +47,7 @@ class MaxSettings(BaseSettingsModel): DEFAULT_VALUES = { "RenderSettings": DEFAULT_RENDER_SETTINGS, + "PreviewAnimation": DEFAULT_PREVIEW_ANIMATION_SETTINGS, "PointCloud": { "attribute": [ {"name": "Age", "value": "age"}, diff --git a/server_addon/max/server/settings/preview_animation.py b/server_addon/max/server/settings/preview_animation.py new file mode 100644 index 0000000000..2496e8e548 --- /dev/null +++ b/server_addon/max/server/settings/preview_animation.py @@ -0,0 +1,92 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def image_format_enum(): + """Return enumerator for image output formats.""" + return [ + {"label": "exr", "value": "exr"}, + {"label": "jpg", "value": "jpg"}, + {"label": "png", "value": "png"}, + {"label": "tga", "value": "tga"} + ] + + +def visual_style_enum(): + """Return enumerator for viewport visual style.""" + return [ + {"label": "Realistic", "value": "Realistic"}, + {"label": "Shaded", "value": "Shaded"}, + {"label": "Facets", "value": "Facets"}, + {"label": "ConsistentColors", + "value": "ConsistentColors"}, + {"label": "Wireframe", "value": "Wireframe"}, + {"label": "BoundingBox", "value": "BoundingBox"}, + {"label": "Ink", "value": "Ink"}, + {"label": "ColorInk", "value": "ColorInk"}, + {"label": "Acrylic", "value": "Acrylic"}, + {"label": "Tech", "value": "Tech"}, + {"label": "Graphite", "value": "Graphite"}, + {"label": "ColorPencil", "value": "ColorPencil"}, + {"label": "Pastel", "value": "Pastel"}, + {"label": "Clay", "value": "Clay"}, + {"label": "ModelAssist", "value": "ModelAssist"} + ] + +def visual_preset_enum(): + """Return enumerator for viewport visual preset.""" + return [ + {"label": "Quality", "value": "Quality"}, + {"label": "Standard", "value": "Standard"}, + {"label": "Performance", "value": "Performance"}, + {"label": "DXMode", "value": "DXMode"}, + {"label": "Customize", "value": "Customize"}, + ] + + +def anti_aliasing_enum(): + """Return enumerator for viewport anti-aliasing.""" + return [ + {"label": "None", "value": "None"}, + {"label": "2X", "value": "2X"}, + {"label": "4X", "value": "4X"}, + {"label": "8X", "value": "8X"} + ] + + +class PreviewAnimationModel(BaseSettingsModel): + review_width: int = Field(1920, title="Review Width") + review_height: int = Field(1080, title="Review Height") + percentSize: float = Field(100.0, title="Percent of Output") + keep_images: bool = Field(False, title="Keep Image Sequences") + image_format: str = Field( + enum_resolver=image_format_enum, + title="Image Format Options" + ) + visual_style: str = Field( + enum_resolver=visual_style_enum, + title="Preference" + ) + viewport_preset: str = Field( + enum_resolver=visual_preset_enum, + title="Pre-View Preset" + ) + anti_aliasing: str = Field( + enum_resolver=anti_aliasing_enum, + title="Anti-aliasing Quality" + ) + vp_texture: bool = Field(True, title="Viewport Texture") + + +DEFAULT_PREVIEW_ANIMATION_SETTINGS = { + "review_width": 1920, + "review_height": 1080, + "percentSize": 100.0, + "keep_images": False, + "image_format": "png", + "visual_style": "Realistic", + "viewport_preset": "Quality", + "anti_aliasing": "None", + "vp_texture": True +} diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From 2728379bbc0810155447cae35c01c65341ec12bd Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 16:04:39 +0800 Subject: [PATCH 0970/1224] hound --- server_addon/max/server/settings/preview_animation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server_addon/max/server/settings/preview_animation.py b/server_addon/max/server/settings/preview_animation.py index 2496e8e548..759ce78291 100644 --- a/server_addon/max/server/settings/preview_animation.py +++ b/server_addon/max/server/settings/preview_animation.py @@ -34,6 +34,7 @@ def visual_style_enum(): {"label": "ModelAssist", "value": "ModelAssist"} ] + def visual_preset_enum(): """Return enumerator for viewport visual preset.""" return [ From 8fb7266ff8f0ea9c3b3f387ca120280093993294 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:18:09 +0200 Subject: [PATCH 0971/1224] Add CollectAssetHandles and modify associated files --- openpype/hosts/houdini/api/lib.py | 20 +-- .../plugins/publish/collect_arnold_rop.py | 2 +- .../plugins/publish/collect_asset_handles.py | 125 ++++++++++++++++++ .../houdini/plugins/publish/collect_frames.py | 10 +- .../plugins/publish/collect_karma_rop.py | 2 +- .../plugins/publish/collect_mantra_rop.py | 2 +- .../plugins/publish/collect_redshift_rop.py | 2 +- .../publish/collect_rop_frame_range.py | 79 +---------- .../plugins/publish/collect_vray_rop.py | 2 +- .../plugins/publish/validate_frame_range.py | 2 +- .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_publish.json | 4 +- .../houdini/server/settings/publish.py | 2 +- 13 files changed, 151 insertions(+), 103 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/collect_asset_handles.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index ac375c56d6..c6722fb1bb 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -569,9 +569,9 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node, handle_start=0, handle_end=0, log=None): - """Get the frame data: start frame, end frame, steps, - start frame with start handle and end frame with end handle. +def get_frame_data(node, log=None): + """Get the frame data: `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. This function uses Houdini node's `trange`, `t1, `t2` and `t3` parameters as the source of truth for the full inclusive frame @@ -579,20 +579,17 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): range including the handles. The non-inclusive frame start and frame end without handles - are computed by subtracting the handles from the inclusive + can be computed by subtracting the handles from the inclusive frame range. Args: node (hou.Node): ROP node to retrieve frame range from, the frame range is assumed to be the frame range *including* the start and end handles. - handle_start (int): Start handles. - handle_end (int): End handles. - log (logging.Logger): Logger to log to. Returns: - dict: frame data for start, end, steps, - start with handle and end with handle + dict: frame data for `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. """ @@ -623,11 +620,6 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") - data["handleStart"] = handle_start - data["handleEnd"] = handle_end - data["frameStart"] = data["frameStartHandle"] + data["handleStart"] - data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 420a8324fe..d95f763826 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -22,7 +22,7 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): label = "Arnold ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py new file mode 100644 index 0000000000..5c11948608 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Collector plugin for frames data on ROP instances.""" +import hou # noqa +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectAssetHandles(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Apply asset handles. + + If instance does not have: + - frameStart + - frameEnd + - handleStart + - handleEnd + But it does have: + - frameStartHandle + - frameEndHandle + + Then we will retrieve the asset's handles to compute + the exclusive frame range and actual handle ranges. + """ + + hosts = ["houdini"] + + # This specific order value is used so that + # this plugin runs after CollectAnatomyInstanceData + order = pyblish.api.CollectorOrder + 0.499 + + label = "Collect Asset Handles" + use_asset_handles = True + + + def process(self, instance): + # Only process instances without already existing handles data + # but that do have frameStartHandle and frameEndHandle defined + # like the data collected from CollectRopFrameRange + if "frameStartHandle" not in instance.data: + return + if "frameEndHandle" not in instance.data: + return + + has_existing_data = { + "handleStart", + "handleEnd", + "frameStart", + "frameEnd" + }.issubset(instance.data) + if has_existing_data: + return + + attr_values = self.get_attr_values_from_data(instance.data) + if attr_values.get("use_handles", self.use_asset_handles): + asset_data = instance.data["assetEntity"]["data"] + handle_start = asset_data.get("handleStart", 0) + handle_end = asset_data.get("handleEnd", 0) + else: + handle_start = 0 + handle_end = 0 + + frame_start = instance.data["frameStartHandle"] + handle_start + frame_end = instance.data["frameEndHandle"] - handle_end + + instance.data.update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": frame_start, + "frameEnd": frame_end + }) + + # Log debug message about the collected frame range + if attr_values.get("use_handles", self.use_asset_handles): + self.log.debug( + "Full Frame range with Handles " + "[{frame_start_handle} - {frame_end_handle}]" + .format( + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + else: + self.log.debug( + "Use handles is deactivated for this instance, " + "start and end handles are set to 0." + ) + + # Log collected frame range to the user + message = "Frame range [{frame_start} - {frame_end}]".format( + frame_start=frame_start, + frame_end=frame_end + ) + if handle_start or handle_end: + message += " with handles [{handle_start}]-[{handle_end}]".format( + handle_start=handle_start, + handle_end=handle_end + ) + self.log.info(message) + + if instance.data.get("byFrameStep", 1.0) != 1.0: + self.log.info( + "Frame steps {}".format(instance.data["byFrameStep"])) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{label} [{frame_start} - {frame_end}]" + .format( + label=label, + frame_start=frame_start, + frame_end=frame_end + ) + ) + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("use_handles", + tooltip="Disable this if you want the publisher to" + " ignore start and end handles specified in the" + " asset data for this publish instance", + default=cls.use_asset_handles, + label="Use asset handles") + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 79cfcc6139..f6f538f5a5 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -13,7 +13,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # This specific order value is used so that # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "bgeo"] @@ -22,8 +22,8 @@ class CollectFrames(pyblish.api.InstancePlugin): ropnode = hou.node(instance.data["instance_node"]) - start_frame = instance.data.get("frameStart", None) - end_frame = instance.data.get("frameEnd", None) + start_frame = instance.data.get("frameStartHandle", None) + end_frame = instance.data.get("frameEndHandle", None) output_parm = lib.get_output_parameter(ropnode) if start_frame is not None: @@ -53,7 +53,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result if end_frame - start_frame > 0: - result = self.create_file_list( + result = self.create_file_list(self, match, int(start_frame), int(end_frame) ) @@ -62,7 +62,7 @@ class CollectFrames(pyblish.api.InstancePlugin): instance.data.update({"frames": result}) @staticmethod - def create_file_list(match, start_frame, end_frame): + def create_file_list(self,match, start_frame, end_frame): """Collect files based on frame range and `regex.match` Args: diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index a477529df9..dac350a6ef 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -26,7 +26,7 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): label = "Karma ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 9f0ae8d33c..a3e7927807 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -26,7 +26,7 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): label = "Mantra ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index 0bd7b41641..0acddab011 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -26,7 +26,7 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): label = "Redshift ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 186244fedd..1e6bc3b16e 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -2,22 +2,15 @@ """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api -from openpype.lib import BoolDef from openpype.hosts.houdini.api import lib -from openpype.pipeline import OpenPypePyblishPluginMixin -class CollectRopFrameRange(pyblish.api.InstancePlugin, - OpenPypePyblishPluginMixin): - +class CollectRopFrameRange(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" hosts = ["houdini"] - # This specific order value is used so that - # this plugin runs after CollectAnatomyInstanceData - order = pyblish.api.CollectorOrder + 0.499 + order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" - use_asset_handles = True def process(self, instance): @@ -30,78 +23,16 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return ropnode = hou.node(node_path) - - attr_values = self.get_attr_values_from_data(instance.data) - - if attr_values.get("use_handles", self.use_asset_handles): - asset_data = instance.data["assetEntity"]["data"] - handle_start = asset_data.get("handleStart", 0) - handle_end = asset_data.get("handleEnd", 0) - else: - handle_start = 0 - handle_end = 0 - frame_data = lib.get_frame_data( - ropnode, handle_start, handle_end, self.log + ropnode, self.log ) if not frame_data: return # Log debug message about the collected frame range - frame_start = frame_data["frameStart"] - frame_end = frame_data["frameEnd"] - - if attr_values.get("use_handles", self.use_asset_handles): - self.log.debug( - "Full Frame range with Handles " - "[{frame_start_handle} - {frame_end_handle}]" - .format( - frame_start_handle=frame_data["frameStartHandle"], - frame_end_handle=frame_data["frameEndHandle"] - ) - ) - else: - self.log.debug( - "Use handles is deactivated for this instance, " - "start and end handles are set to 0." - ) - - # Log collected frame range to the user - message = "Frame range [{frame_start} - {frame_end}]".format( - frame_start=frame_start, - frame_end=frame_end + self.log.debug( + "Collected frame_data: {}".format(frame_data) ) - if handle_start or handle_end: - message += " with handles [{handle_start}]-[{handle_end}]".format( - handle_start=handle_start, - handle_end=handle_end - ) - self.log.info(message) - - if frame_data.get("byFrameStep", 1.0) != 1.0: - self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) instance.data.update(frame_data) - - # Add frame range to label if the instance has a frame range. - label = instance.data.get("label", instance.data["name"]) - instance.data["label"] = ( - "{label} [{frame_start} - {frame_end}]" - .format( - label=label, - frame_start=frame_start, - frame_end=frame_end - ) - ) - - @classmethod - def get_attribute_defs(cls): - return [ - BoolDef("use_handles", - tooltip="Disable this if you want the publisher to" - " ignore start and end handles specified in the" - " asset data for this publish instance", - default=cls.use_asset_handles, - label="Use asset handles") - ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 519c12aede..64de2079cd 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -26,7 +26,7 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): label = "VRay ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames - order = pyblish.api.CollectorOrder + 0.49999 + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["vray_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 6a66f3de9f..b49cfae901 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -89,7 +89,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): .format(instance)) return - created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa + created_instance.publish_attributes["CollectAssetHandles"]["use_handles"] = False # noqa create_context.save_changes() cls.log.debug("use asset handles is turned off for '{}'" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 87983620ec..0dd8443e44 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -137,7 +137,7 @@ } }, "publish": { - "CollectRopFrameRange": { + "CollectAssetHandles": { "use_asset_handles": true }, "ValidateContainers": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index 0de9f21c9f..324cfd8d58 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -11,8 +11,8 @@ { "type": "dict", "collapsible": true, - "key": "CollectRopFrameRange", - "label": "Collect Rop Frame Range", + "key": "CollectAssetHandles", + "label": "Collect Asset Handles", "children": [ { "type": "label", diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 6615e34ca5..342bf957c1 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -3,7 +3,7 @@ from ayon_server.settings import BaseSettingsModel # Publish Plugins -class CollectRopFrameRangeModel(BaseSettingsModel): +class CollectAssetHandlesModel(BaseSettingsModel): """Collect Frame Range Disable this if you want the publisher to ignore start and end handles specified in the From 38d71e7c73ef5716b2964c867fe81d1b26656aed Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:19:08 +0200 Subject: [PATCH 0972/1224] Bump Houdini addon version --- server_addon/houdini/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 6cd38b7465..c49a95c357 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__ = "0.2.8" From 3d74095521a45337d764a70eaf794fa7f98dc226 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 11:21:10 +0200 Subject: [PATCH 0973/1224] Resolve Hound and Remove debug code --- .../hosts/houdini/plugins/publish/collect_asset_handles.py | 1 - openpype/hosts/houdini/plugins/publish/collect_frames.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py index 5c11948608..6474d64765 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -32,7 +32,6 @@ class CollectAssetHandles(pyblish.api.InstancePlugin, label = "Collect Asset Handles" use_asset_handles = True - def process(self, instance): # Only process instances without already existing handles data # but that do have frameStartHandle and frameEndHandle defined diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index f6f538f5a5..cdef642174 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -53,7 +53,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result if end_frame - start_frame > 0: - result = self.create_file_list(self, + result = self.create_file_list( match, int(start_frame), int(end_frame) ) @@ -62,7 +62,7 @@ class CollectFrames(pyblish.api.InstancePlugin): instance.data.update({"frames": result}) @staticmethod - def create_file_list(self,match, start_frame, end_frame): + def create_file_list(match, start_frame, end_frame): """Collect files based on frame range and `regex.match` Args: From 69bf065851a7b82db1779a72022abf941d5c56bd Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 15:41:37 +0200 Subject: [PATCH 0974/1224] BigRoy's Comment - Update label --- .../hosts/houdini/plugins/publish/collect_asset_handles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py index 6474d64765..67a281639d 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -104,11 +104,11 @@ class CollectAssetHandles(pyblish.api.InstancePlugin, # Add frame range to label if the instance has a frame range. label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( - "{label} [{frame_start} - {frame_end}]" + "{label} [{frame_start_handle} - {frame_end_handle}]" .format( label=label, - frame_start=frame_start, - frame_end=frame_end + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] ) ) From 2d3ae5a0d346a95586abde0a11632291e8c83467 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:02:13 +0800 Subject: [PATCH 0975/1224] supports checking the required plugins with families type * --- .../plugins/publish/validate_loaded_plugin.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index dc82c7ed65..d6f849a57e 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -16,7 +16,11 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation - via studio defined project settings""" + via studio defined project settings + If families = ["*"], all the required plugins would be validated + If families + + """ order = ValidatorOrder hosts = ["max"] @@ -48,15 +52,17 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, } for families, plugin in required_plugins.items(): - families_list = families.split(",") - excluded_families = [family for family in families_list - if instance.data["family"] != family - and family != "_"] - if excluded_families: - self.log.debug("The {} instance is not part of {}.".format( - instance.data["family"], excluded_families - )) - return + # Out of for loop build the instance family lookup + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + self.log.debug(f"{instance_families}") + # In the for loop check whether any family matches + match_families = {fam.strip() for fam in families.split(",") if fam.strip()} + self.log.debug(f"match_families: {match_families}") + has_match = "*" in match_families or match_families.intersection( + instance_families) or families == "_" + if not has_match: + continue if not plugin: return @@ -66,7 +72,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if plugin_index is None: invalid.append( - f"Plugin {plugin} not exists in 3dsMax Plugin List." + f"Plugin {plugin} does not exist in 3dsMax Plugin List." ) continue @@ -82,12 +88,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, "- {}".format(invalid) for invalid in invalid_plugins ) report = ( - "Required plugins fails to load.\n\n" + "Required plugins are not loaded.\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to load the plugin." ) raise PublishValidationError( - report, title="Required Plugins unloaded") + report, title="Missing Required Plugins") @classmethod def repair(cls, instance): From e61d03556a1a7d915a6cd421c6a0ec45d40c8481 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:03:00 +0800 Subject: [PATCH 0976/1224] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index d6f849a57e..e58685cc4d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -57,7 +57,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families.update(instance.data.get("families", [])) self.log.debug(f"{instance_families}") # In the for loop check whether any family matches - match_families = {fam.strip() for fam in families.split(",") if fam.strip()} + match_families = {fam.strip() for fam in + families.split(",") if fam.strip()} self.log.debug(f"match_families: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) or families == "_" From 6e9c3b227815e7e8cbd158a8110549b2f095443e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 3 Nov 2023 15:06:59 +0100 Subject: [PATCH 0977/1224] deadline: settings are not blocking extension input --- server_addon/deadline/server/settings/publish_plugins.py | 2 +- server_addon/deadline/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d48695a9c..54b7ff57c1 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -267,7 +267,7 @@ class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): title="Reviewable products filter", ) - @validator("aov_filter", "skip_integration_repre_list") + @validator("aov_filter") def validate_unique_names(cls, value): ensure_unique_names(value) return value diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 44a0b0e6284a73180dccdef244fe92c5761caaf0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:42:44 +0800 Subject: [PATCH 0978/1224] make sure the aspect ratio correct --- openpype/hosts/max/api/preview_animation.py | 51 +++++++++++---------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index 0754fa61c4..d6becd499e 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -166,13 +166,16 @@ def _render_preview_animation_max_2024( def _render_preview_animation_max_pre_2024( - filepath, startFrame, endFrame, percentSize, ext): + filepath, startFrame, endFrame, + width, height, percentSize, ext): """Render viewport animation by creating bitmaps ***For 3dsMax Version <2024 Args: filepath (str): filepath without frame numbers and extension startFrame (int): start frame endFrame (int): end frame + width (int): render resolution width + height (int): render resolution height percentSize (float): render resolution multiplier by 100 e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x ext (str): image extension @@ -182,9 +185,8 @@ def _render_preview_animation_max_pre_2024( # get the screenshot percent = percentSize / 100.0 - res_width = int(round(rt.renderWidth * percent)) - res_height = int(round(rt.renderHeight * percent)) - viewportRatio = float(res_width / res_height) + res_width = int(round(width * percent)) + res_height = int(round(height * percent)) frame_template = "{}.{{:04}}.{}".format(filepath, ext) frame_template.replace("\\", "/") files = [] @@ -196,10 +198,11 @@ def _render_preview_animation_max_pre_2024( res_width, res_height, filename=filepath ) dib = rt.gw.getViewportDib() - dib_width = float(dib.width) - dib_height = float(dib.height) + dib_width = rt.renderWidth + dib_height = rt.renderHeight # aspect ratio - renderRatio = rt.getRendImageAspect() + viewportRatio = dib_width /dib_height + renderRatio = float(res_width / res_height) if viewportRatio <= renderRatio: heightCrop = (dib_width / renderRatio) topEdge = int((dib_height - heightCrop) / 2.0) @@ -263,22 +266,24 @@ def render_preview_animation( viewport_options = viewport_options_for_preview_animation() with play_preview_when_done(False): with viewport_layout_and_camera(camera): - with render_resolution(width, height): - if int(get_max_version()) < 2024: - with viewport_preference_setting( - viewport_options["general_viewport"], - viewport_options["nitrous_manager"], - viewport_options["nitrous_viewport"], - viewport_options["vp_btn_mgr"] - ): - return _render_preview_animation_max_pre_2024( - filepath, - start_frame, - end_frame, - percentSize, - ext - ) - else: + if int(get_max_version()) < 2024: + with viewport_preference_setting( + viewport_options["general_viewport"], + viewport_options["nitrous_manager"], + viewport_options["nitrous_viewport"], + viewport_options["vp_btn_mgr"] + ): + return _render_preview_animation_max_pre_2024( + filepath, + start_frame, + end_frame, + width, + height, + percentSize, + ext + ) + else: + with render_resolution(width, height): return _render_preview_animation_max_2024( filepath, start_frame, From 1132d1c9f3eb7d512b548988e717b16563dde119 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 15:43:33 +0100 Subject: [PATCH 0979/1224] avoid double slashes in context title path --- openpype/host/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/host/host.py b/openpype/host/host.py index 630fb873a8..afe06d1f55 100644 --- a/openpype/host/host.py +++ b/openpype/host/host.py @@ -170,7 +170,7 @@ class HostBase(object): if project_name: items.append(project_name) if asset_name: - items.append(asset_name) + items.append(asset_name.lstrip("/")) if task_name: items.append(task_name) if items: From 01c965eb9cb701eee833cb735dcec0daf4f6f61a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:43:34 +0800 Subject: [PATCH 0980/1224] hound --- openpype/hosts/max/api/preview_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index d6becd499e..bd0fee3658 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -201,7 +201,7 @@ def _render_preview_animation_max_pre_2024( dib_width = rt.renderWidth dib_height = rt.renderHeight # aspect ratio - viewportRatio = dib_width /dib_height + viewportRatio = dib_width / dib_height renderRatio = float(res_width / res_height) if viewportRatio <= renderRatio: heightCrop = (dib_width / renderRatio) From 49154c0750ab45ffeb7bd413ab99b79a6f6aadef Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 16:45:12 +0100 Subject: [PATCH 0981/1224] fix create multishot layout --- .../maya/plugins/create/create_multishot_layout.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py index 0b027c02ea..7cd3fdbd17 100644 --- a/openpype/hosts/maya/plugins/create/create_multishot_layout.py +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -45,10 +45,14 @@ class CreateMultishotLayout(plugin.MayaCreator): above is done. """ - current_folder = get_folder_by_name( - project_name=get_current_project_name(), - folder_name=get_current_asset_name(), - ) + project_name = get_current_project_name() + folder_path = get_current_asset_name() + if "/" in folder_path: + current_folder = get_folder_by_path(project_name, folder_path) + else: + current_folder = get_folder_by_name( + project_name, folder_name=folder_path + ) current_path_parts = current_folder["path"].split("/") From 9154dbab05ec7f5b921874b5d523720df7c9409a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 15:45:15 +0000 Subject: [PATCH 0982/1224] Fix loading of blend layout --- openpype/hosts/blender/plugins/load/load_blend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 25d6568889..3d6b634916 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -32,7 +32,7 @@ class BlendLoader(plugin.AssetLoader): empties = [obj for obj in objects if obj.type == 'EMPTY'] for empty in empties: - if empty.get(AVALON_PROPERTY): + if empty.get(AVALON_PROPERTY) and empty.parent is None: return empty return None @@ -90,6 +90,7 @@ class BlendLoader(plugin.AssetLoader): members.append(data) container = self._get_asset_container(data_to.objects) + print(container) assert container, "No asset group found" container.name = group_name @@ -100,8 +101,11 @@ class BlendLoader(plugin.AssetLoader): # Link all the container children to the collection for obj in container.children_recursive: + print(obj) bpy.context.scene.collection.objects.link(obj) + print("") + # Remove the library from the blend file library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) From dbd1fcb98912616d03c456718baf9b3f2e65a03c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 17:03:54 +0100 Subject: [PATCH 0983/1224] make sure all QThread objects are always removed from python memory --- .../ayon_utils/widgets/folders_widget.py | 3 +- .../ayon_utils/widgets/projects_widget.py | 13 +++-- .../tools/ayon_utils/widgets/tasks_widget.py | 49 ++++++++++--------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 322553c51c..b72a992858 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -104,8 +104,8 @@ class FoldersModel(QtGui.QStandardItemModel): if not project_name: self._last_project_name = project_name - self._current_refresh_thread = None self._fill_items({}) + self._current_refresh_thread = None return self._is_refreshing = True @@ -152,6 +152,7 @@ class FoldersModel(QtGui.QStandardItemModel): return self._fill_items(thread.get_result()) + self._current_refresh_thread = None def _fill_item_data(self, item, folder_item): """ diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index be18cfe3ed..05347faca4 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -35,12 +35,11 @@ class ProjectsModel(QtGui.QStandardItemModel): self._selected_project = None - self._is_refreshing = False self._refresh_thread = None @property def is_refreshing(self): - return self._is_refreshing + return self._refresh_thread is not None def refresh(self): self._refresh() @@ -169,15 +168,16 @@ class ProjectsModel(QtGui.QStandardItemModel): return self._select_item def _refresh(self): - if self._is_refreshing: + if self._refresh_thread is not None: return - self._is_refreshing = True + refresh_thread = RefreshThread( "projects", self._query_project_items ) refresh_thread.refresh_finished.connect(self._refresh_finished) - refresh_thread.start() + self._refresh_thread = refresh_thread + refresh_thread.start() def _query_project_items(self): return self._controller.get_project_items() @@ -185,11 +185,10 @@ class ProjectsModel(QtGui.QStandardItemModel): def _refresh_finished(self): # TODO check if failed result = self._refresh_thread.get_result() - self._refresh_thread = None self._fill_items(result) - self._is_refreshing = False + self._refresh_thread = None self.refreshed.emit() def _fill_items(self, project_items): diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index d01b3a7917..a6375c6ae6 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -185,28 +185,7 @@ class TasksModel(QtGui.QStandardItemModel): thread.refresh_finished.connect(self._on_refresh_thread) thread.start() - def _on_refresh_thread(self, thread_id): - """Callback when refresh thread is finished. - - Technically can be running multiple refresh threads at the same time, - to avoid using values from wrong thread, we check if thread id is - current refresh thread id. - - Tasks are stored by name, so if a folder has same task name as - previously selected folder it keeps the selection. - - Args: - thread_id (str): Thread id. - """ - - # Make sure to remove thread from '_refresh_threads' dict - thread = self._refresh_threads.pop(thread_id) - if ( - self._current_refresh_thread is None - or thread_id != self._current_refresh_thread.id - ): - return - + def _fill_data_from_thread(self, thread): task_items = thread.get_result() # Task items are refreshed if task_items is None: @@ -247,7 +226,33 @@ class TasksModel(QtGui.QStandardItemModel): if new_items: root_item.appendRows(new_items) + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None self._is_refreshing = False self.refreshed.emit() From 10aea1088a68f49218255e4988febe4bc1f0f462 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Fri, 3 Nov 2023 17:06:44 +0100 Subject: [PATCH 0984/1224] Add collect workfile to blender host --- .../plugins/publish/collect_workfile.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 openpype/hosts/blender/plugins/publish/collect_workfile.py diff --git a/openpype/hosts/blender/plugins/publish/collect_workfile.py b/openpype/hosts/blender/plugins/publish/collect_workfile.py new file mode 100644 index 0000000000..e431405e80 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_workfile.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import bpy +from pyblish.api import InstancePlugin, CollectorOrder + + +class CollectWorkfile(InstancePlugin): + """Inject workfile data into its instance.""" + + order = CollectorOrder + label = "Collect Workfile" + hosts = ["blender"] + families = ["workfile"] + + def process(self, instance): + """Process collector.""" + + context = instance.context + filepath = Path(context.data["currentFile"]) + ext = filepath.suffix + + instance.data.update( + { + "setMembers": [filepath.as_posix()], + "frameStart": context.data.get("frameStart", 1), + "frameEnd": context.data.get("frameEnd", 1), + "handleStart": context.data.get("handleStart", 1), + "handledEnd": context.data.get("handleEnd", 1), + "representations": [ + { + "name": ext.lstrip("."), + "ext": ext, + "files": filepath.name, # TODO resources + "stagingDir": filepath.parent, + } + ], + } + ) From f18d0d9f8f36fcdc7193aaea5588db391fc5acec Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 18:09:48 +0200 Subject: [PATCH 0985/1224] include full frame range in representation dictionary --- openpype/hosts/houdini/plugins/publish/extract_ass.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_bgeo.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_composite.py | 4 ++-- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 6 +++--- openpype/hosts/houdini/plugins/publish/extract_opengl.py | 4 ++-- .../hosts/houdini/plugins/publish/extract_redshift_proxy.py | 6 +++--- openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_ass.py b/openpype/hosts/houdini/plugins/publish/extract_ass.py index 0d246625ba..be60217055 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_ass.py +++ b/openpype/hosts/houdini/plugins/publish/extract_ass.py @@ -56,7 +56,7 @@ class ExtractAss(publish.Extractor): 'ext': ext, "files": files, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index c9625ec880..d13141b426 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -47,7 +47,7 @@ class ExtractBGEO(publish.Extractor): "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"] + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"] } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_composite.py b/openpype/hosts/houdini/plugins/publish/extract_composite.py index 7a1ab36b93..11cf83a46d 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_composite.py +++ b/openpype/hosts/houdini/plugins/publish/extract_composite.py @@ -41,8 +41,8 @@ class ExtractComposite(publish.Extractor): "ext": ext, "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } from pprint import pformat diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 7993b3352f..1be61ecce1 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -40,9 +40,9 @@ class ExtractFBX(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] # set value type for 'representations' key to list if "representations" not in instance.data: diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py index 6c36dec5f5..38808089ac 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_opengl.py +++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py @@ -39,8 +39,8 @@ class ExtractOpenGL(publish.Extractor): "ext": instance.data["imageFormat"], "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": instance.data.get("review_camera") diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 1d99ac665c..18cbd5712e 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -44,8 +44,8 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 4bca758f08..89af8e1756 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -40,7 +40,7 @@ class ExtractVDBCache(publish.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) From 821b478830f2f75f733d82374bd06195db01e9d8 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 16:50:11 +0000 Subject: [PATCH 0986/1224] Get the selection when creating the instance --- .../plugins/create/create_blendScene.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 96e63924d3..970be157b9 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -15,6 +15,8 @@ class CreateBlendScene(plugin.Creator): family = "blendScene" icon = "cubes" + maintain_selection = False + def process(self): """ Run the creator on Blender main thread""" mti = ops.MainThreadItem(self._process) @@ -38,4 +40,29 @@ class CreateBlendScene(plugin.Creator): self.data['task'] = get_current_task_name() lib.imprint(asset_group, self.data) + try: + area = next( + area for area in bpy.context.window.screen.areas + if area.type == 'OUTLINER') + region = next( + region for region in area.regions + if region.type == 'WINDOW') + except StopIteration as e: + raise RuntimeError("Could not find outliner. An outliner space " + "must be in the main Blender window.") from e + + with bpy.context.temp_override( + window=bpy.context.window, + area=area, + region=region, + screen=bpy.context.window.screen + ): + ids = bpy.context.selected_ids + + for id in ids: + if isinstance(id, bpy.types.Collection): + asset_group.children.link(id) + elif isinstance(id, bpy.types.Object): + asset_group.objects.link(id) + return asset_group From b5ebe86b1482d5790296283323e96aab060dbad6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 16:51:21 +0000 Subject: [PATCH 0987/1224] Hound fixes --- openpype/hosts/blender/plugins/create/create_blendScene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 970be157b9..791e741ca7 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -41,7 +41,7 @@ class CreateBlendScene(plugin.Creator): lib.imprint(asset_group, self.data) try: - area = next( + area = next( area for area in bpy.context.window.screen.areas if area.type == 'OUTLINER') region = next( From e6d13db010609fabe648e43a9745f94e2c355a14 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 17:27:25 +0000 Subject: [PATCH 0988/1224] Keeps the transform when updating --- openpype/hosts/blender/plugins/load/load_blendscene.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index fe7afb3119..34030d9d84 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -136,13 +136,18 @@ class BlendSceneLoader(plugin.AssetLoader): f"The asset is not loaded: {container['objectName']}" ) + # Get the parents of the members of the asset group, so we can + # re-link them after the update. + # Also gets the transform for each object to reapply after the update. collection_parents = {} + member_transforms = {} members = asset_group.get(AVALON_PROPERTY).get("members", []) loaded_collections = {c for c in bpy.data.collections if c in members} loaded_collections.add(bpy.data.collections.get(AVALON_CONTAINERS)) for member in members: if isinstance(member, bpy.types.Object): member_parents = set(member.users_collection) + member_transforms[member.name] = member.matrix_basis.copy() elif isinstance(member, bpy.types.Collection): member_parents = { c for c in bpy.data.collections if c.user_of_id(member)} @@ -167,6 +172,9 @@ class BlendSceneLoader(plugin.AssetLoader): parent.objects.link(member) elif isinstance(member, bpy.types.Collection): parent.children.link(member) + if (member.name in member_transforms and + isinstance(member, bpy.types.Object)): + member.matrix_basis = member_transforms[member.name] avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.children.link(asset_group) From 4a9f1e7d785298b4df94a1a61bc983cdc73c37b9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 17:29:12 +0000 Subject: [PATCH 0989/1224] Hound fixes --- openpype/hosts/blender/plugins/load/load_blendscene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index 34030d9d84..28dcf4fc70 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -174,7 +174,7 @@ class BlendSceneLoader(plugin.AssetLoader): parent.children.link(member) if (member.name in member_transforms and isinstance(member, bpy.types.Object)): - member.matrix_basis = member_transforms[member.name] + member.matrix_basis = member_transforms[member.name] avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.children.link(asset_group) From 8f4d490af25ad8765c6f7ae056a57e00b97a228f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 3 Nov 2023 17:30:25 +0000 Subject: [PATCH 0990/1224] Hound fixes --- openpype/hosts/blender/plugins/load/load_blendscene.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index 28dcf4fc70..b1b2c3ba79 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -172,9 +172,10 @@ class BlendSceneLoader(plugin.AssetLoader): parent.objects.link(member) elif isinstance(member, bpy.types.Collection): parent.children.link(member) - if (member.name in member_transforms and - isinstance(member, bpy.types.Object)): - member.matrix_basis = member_transforms[member.name] + if member.name in member_transforms and isinstance( + member, bpy.types.Object + ): + member.matrix_basis = member_transforms[member.name] avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.children.link(asset_group) From 2a19d5cc548e55bd526f24a70494717fbe5f23c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:57:15 +0100 Subject: [PATCH 0991/1224] fix default type of projects model cache --- openpype/tools/ayon_utils/models/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 4ad53fbbfa..383f676c64 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -87,7 +87,7 @@ def _get_project_items_from_entitiy(projects): class ProjectsModel(object): def __init__(self, controller): - self._projects_cache = CacheItem(default_factory=dict) + self._projects_cache = CacheItem(default_factory=list) self._project_items_by_name = {} self._projects_by_name = {} From 3bffe3b31b3b367e4a24f0446d2b217e3623ecbe Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:58:29 +0100 Subject: [PATCH 0992/1224] renamed 'ProjectsModel' to 'ProjectsQtModel' --- openpype/tools/ayon_launcher/ui/projects_widget.py | 4 ++-- openpype/tools/ayon_utils/widgets/__init__.py | 4 ++-- openpype/tools/ayon_utils/widgets/projects_widget.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index 7dbaec5147..31c36719a6 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets, QtCore from openpype.tools.flickcharm import FlickCharm from openpype.tools.utils import PlaceholderLineEdit, RefreshButton from openpype.tools.ayon_utils.widgets import ( - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER @@ -95,7 +95,7 @@ class ProjectsWidget(QtWidgets.QWidget): projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) flick.activateOn(projects_view) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 432a249a73..1ef7dfe482 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -1,7 +1,7 @@ from .projects_widget import ( # ProjectsWidget, ProjectsCombobox, - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) @@ -25,7 +25,7 @@ from .utils import ( __all__ = ( # "ProjectsWidget", "ProjectsCombobox", - "ProjectsModel", + "ProjectsQtModel", "ProjectSortFilterProxy", "FoldersWidget", diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 05347faca4..9f0f839281 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -10,11 +10,11 @@ PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 -class ProjectsModel(QtGui.QStandardItemModel): +class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ProjectsModel, self).__init__() + super(ProjectsQtModel, self).__init__() self._controller = controller self._project_items = {} @@ -402,7 +402,7 @@ class ProjectsCombobox(QtWidgets.QWidget): projects_combobox = QtWidgets.QComboBox(self) combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) projects_combobox.setModel(projects_proxy_model) From 264e3cac79b863c5eb45841860da7789a13e714f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:59:02 +0100 Subject: [PATCH 0993/1224] renamed other qt models to contain 'Qt' --- openpype/tools/ayon_loader/ui/folders_widget.py | 4 ++-- openpype/tools/ayon_utils/widgets/__init__.py | 8 ++++---- openpype/tools/ayon_utils/widgets/folders_widget.py | 6 +++--- openpype/tools/ayon_utils/widgets/tasks_widget.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 53351f76d9..eaaf7ca617 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -8,7 +8,7 @@ from openpype.tools.utils import ( from openpype.style import get_objected_colors from openpype.tools.ayon_utils.widgets import ( - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE @@ -182,7 +182,7 @@ class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): painter.restore() -class LoaderFoldersModel(FoldersModel): +class LoaderFoldersModel(FoldersQtModel): def __init__(self, *args, **kwargs): super(LoaderFoldersModel, self).__init__(*args, **kwargs) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 1ef7dfe482..f58de17c4a 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -7,13 +7,13 @@ from .projects_widget import ( from .folders_widget import ( FoldersWidget, - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, - TasksModel, + TasksQtModel, TASKS_MODEL_SENDER_NAME, ) from .utils import ( @@ -29,11 +29,11 @@ __all__ = ( "ProjectSortFilterProxy", "FoldersWidget", - "FoldersModel", + "FoldersQtModel", "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", - "TasksModel", + "TasksQtModel", "TASKS_MODEL_SENDER_NAME", "get_qt_icon", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index b72a992858..44323a192c 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -16,7 +16,7 @@ FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class FoldersModel(QtGui.QStandardItemModel): +class FoldersQtModel(QtGui.QStandardItemModel): """Folders model which cares about refresh of folders. Args: @@ -26,7 +26,7 @@ class FoldersModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(FoldersModel, self).__init__() + super(FoldersQtModel, self).__init__() self._controller = controller self._items_by_id = {} @@ -282,7 +282,7 @@ class FoldersWidget(QtWidgets.QWidget): folders_view = TreeView(self) folders_view.setHeaderHidden(True) - folders_model = FoldersModel(controller) + folders_model = FoldersQtModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index a6375c6ae6..f27711acdd 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -12,7 +12,7 @@ ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class TasksModel(QtGui.QStandardItemModel): +class TasksQtModel(QtGui.QStandardItemModel): """Tasks model which cares about refresh of tasks by folder id. Args: @@ -22,7 +22,7 @@ class TasksModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(TasksModel, self).__init__() + super(TasksQtModel, self).__init__() self._controller = controller @@ -285,7 +285,7 @@ class TasksModel(QtGui.QStandardItemModel): if section == 0: return "Tasks" - return super(TasksModel, self).headerData( + return super(TasksQtModel, self).headerData( section, orientation, role ) @@ -310,7 +310,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) - tasks_model = TasksModel(controller) + tasks_model = TasksQtModel(controller) tasks_proxy_model = QtCore.QSortFilterProxyModel() tasks_proxy_model.setSourceModel(tasks_model) tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) From 86e4bed1514a1506200ac3ca9c51956c95414b2b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 18:59:52 +0100 Subject: [PATCH 0994/1224] validate that item on which is clicked is enabled --- openpype/tools/ayon_launcher/ui/projects_widget.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index 31c36719a6..38c7f62bd5 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -133,9 +133,14 @@ class ProjectsWidget(QtWidgets.QWidget): return self._projects_model.has_content() def _on_view_clicked(self, index): - if index.isValid(): - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) + if not index.isValid(): + return + model = index.model() + flags = model.flags(index) + if not flags & QtCore.Qt.ItemIsEnabled: + return + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) def _on_project_filter_change(self, text): self._projects_proxy_model.setFilterFixedString(text) From 05748bbb9291424ed82495a63bc581b2437fe772 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:00:51 +0100 Subject: [PATCH 0995/1224] projects model pass sender value --- openpype/tools/ayon_utils/widgets/projects_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 9f0f839281..804b7a05ac 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -180,7 +180,9 @@ class ProjectsQtModel(QtGui.QStandardItemModel): refresh_thread.start() def _query_project_items(self): - return self._controller.get_project_items() + return self._controller.get_project_items( + sender=PROJECTS_MODEL_SENDER + ) def _refresh_finished(self): # TODO check if failed From 42c32f81969399bc900fdfe2900d0da0e8c3edea Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:01:26 +0100 Subject: [PATCH 0996/1224] projects model returns 'None' if is in middle of refreshing --- openpype/tools/ayon_utils/models/projects.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 383f676c64..36d53edc24 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -103,8 +103,18 @@ class ProjectsModel(object): self._refresh_projects_cache() def get_project_items(self, sender): + """ + + Args: + sender (str): Name of sender who asked for items. + + Returns: + Union[list[ProjectItem], None]: List of project items, or None + if model is refreshing. + """ + if not self._projects_cache.is_valid: - self._refresh_projects_cache(sender) + return self._refresh_projects_cache(sender) return self._projects_cache.get_data() def get_project_entity(self, project_name): @@ -136,11 +146,12 @@ class ProjectsModel(object): def _refresh_projects_cache(self, sender=None): if self._is_refreshing: - return + return None with self._project_refresh_event_manager(sender): project_items = self._query_projects() self._projects_cache.update_data(project_items) + return self._projects_cache.get_data() def _query_projects(self): projects = ayon_api.get_projects(fields=["name", "active", "library"]) From 5a0b2f69153f71469b60acf78d588c3583493764 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:01:40 +0100 Subject: [PATCH 0997/1224] projects model handle cases when model is refreshing --- openpype/tools/ayon_utils/widgets/projects_widget.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 804b7a05ac..2beee29cb9 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -1,3 +1,5 @@ +import uuid + from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER @@ -187,11 +189,14 @@ class ProjectsQtModel(QtGui.QStandardItemModel): def _refresh_finished(self): # TODO check if failed result = self._refresh_thread.get_result() - - self._fill_items(result) + if result is not None: + self._fill_items(result) self._refresh_thread = None - self.refreshed.emit() + if result is None: + self._refresh() + else: + self.refreshed.emit() def _fill_items(self, project_items): new_project_names = { From 779cea668862a9354816f5fb9c62bf0d71496951 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 20:04:16 +0200 Subject: [PATCH 0998/1224] update dictionary keys to be frames with handles --- .../hosts/houdini/plugins/publish/collect_review_data.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py index 3efb75e66c..9671945b9a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_review_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py @@ -6,6 +6,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): """Collect Review Data.""" label = "Collect Review Data" + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 hosts = ["houdini"] families = ["review"] @@ -41,8 +43,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): return if focal_length_parm.isTimeDependent(): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + 1 + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + 1 focal_length = [ focal_length_parm.evalAsFloatAtFrame(t) for t in range(int(start), int(end)) From 15a52469354238165236ed890d10679e932f4439 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 3 Nov 2023 20:07:15 +0200 Subject: [PATCH 0999/1224] Resolve Hound --- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 2 +- .../hosts/houdini/plugins/publish/extract_redshift_proxy.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 1be61ecce1..7dc193c6a9 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -40,7 +40,7 @@ class ExtractFBX(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 18cbd5712e..ef5991924f 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -44,7 +44,7 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] From d87506f8af15123cdc0d07176dba26bce262a434 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 3 Nov 2023 19:12:17 +0100 Subject: [PATCH 1000/1224] removed unused import --- openpype/tools/ayon_utils/widgets/projects_widget.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index 2beee29cb9..f98bfcdf8a 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -1,5 +1,3 @@ -import uuid - from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER From bd94b8dcc972fe8f954b9b10de46350d1515864c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 4 Nov 2023 03:25:15 +0000 Subject: [PATCH 1001/1224] [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 4865fcfb31..c6ebd65e9c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.2" +__version__ = "3.17.5-nightly.3" From 2ab07ec7ede740c13d98e8abeca9c02f73db4182 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 4 Nov 2023 03:25:51 +0000 Subject: [PATCH 1002/1224] 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 249da3da0e..7a1fe9d83e 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.5-nightly.3 - 3.17.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.5 - 3.15.1-nightly.4 - 3.15.1-nightly.3 - - 3.15.1-nightly.2 validations: required: true - type: dropdown From b079ca8d0f76ede60866dcb26f506189cbfcea9e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 6 Nov 2023 15:41:20 +0800 Subject: [PATCH 1003/1224] clean up the duplicated variable --- openpype/settings/ayon_settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 88fbbd5124..fa73199269 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -649,7 +649,6 @@ def _convert_3dsmax_project_settings(ayon_settings, output): attributes = {} ayon_publish["ValidateAttributes"]["attributes"] = attributes - ayon_publish = ayon_max["publish"] if "ValidateLoadedPlugin" in ayon_publish: family_plugin_mapping = ( ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] From 7b9c6c3b99900dde8ea841a2bfe25ea02f4cbeff Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 6 Nov 2023 10:30:13 +0000 Subject: [PATCH 1004/1224] Added validator to verify that the instance is not empty --- .../publish/validate_instance_empty.py | 18 ++++++++++ .../defaults/project_settings/blender.json | 5 +++ .../schemas/schema_blender_publish.json | 16 +++++++++ .../server/settings/publish_plugins.py | 35 ++++++++++++------- server_addon/blender/server/version.py | 2 +- 5 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/validate_instance_empty.py diff --git a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py new file mode 100644 index 0000000000..66d8b45e1e --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py @@ -0,0 +1,18 @@ +import bpy + +import pyblish.api + + +class ValidateInstanceEmpty(pyblish.api.InstancePlugin): + """Validator to verify that the instance is not empty""" + + order = pyblish.api.ValidatorOrder - 0.01 + hosts = ["blender"] + families = ["blendScene"] + label = "Validate Instance is not Empty" + optional = False + + def process(self, instance): + collection = bpy.data.collections[instance.name] + if not (collection.objects or collection.children): + raise RuntimeError(f"Instance {instance.name} is empty.") diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 7fb8c333a6..385e97ef91 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -71,6 +71,11 @@ "optional": false, "active": true }, + "ValidateInstanceEmpty": { + "enabled": true, + "optional": false, + "active": true + }, "ExtractBlend": { "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 b84c663e6c..e4f1096223 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 @@ -79,6 +79,22 @@ } ] }, + { + "type": "collapsible-wrap", + "label": "BlendScene", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateInstanceEmpty", + "label": "Validate Instance is not Empty" + } + ] + } + ] + }, { "type": "collapsible-wrap", "label": "Render", diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 27dc0b232f..bb68b40cbb 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -61,26 +61,16 @@ class PublishPuginsModel(BaseSettingsModel): ValidateCameraZeroKeyframe: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Camera Zero Keyframe", - section="Validators" + section="General 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" - ) - 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" + title="Validate Mesh Has Uvs", + section="Model Validators" ) ValidateMeshNoNegativeScale: ValidatePluginModel = Field( default_factory=ValidatePluginModel, @@ -94,6 +84,20 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Validate No Colons In Name" ) + ValidateInstanceEmpty: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Instance is not Empty", + section="BlendScene Validators" + ) + ValidateRenderCameraIsSet: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Camera Is Set", + section="Render Validators" + ) + ValidateDeadlinePublish: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Output for Deadline", + ) ExtractBlend: ExtractBlendModel = Field( default_factory=ExtractBlendModel, title="Extract Blend", @@ -179,6 +183,11 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateInstanceEmpty": { + "enabled": True, + "optional": False, + "active": True + }, "ExtractBlend": { "enabled": True, "optional": True, diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index ae7362549b..1276d0254f 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.5" From 026fa6567680e4079c20158f16edad466b777999 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 6 Nov 2023 21:19:30 +0800 Subject: [PATCH 1005/1224] value tweaks on aspect ratio --- openpype/hosts/max/api/preview_animation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py index bd0fee3658..bbf05f4ca9 100644 --- a/openpype/hosts/max/api/preview_animation.py +++ b/openpype/hosts/max/api/preview_animation.py @@ -185,8 +185,8 @@ def _render_preview_animation_max_pre_2024( # get the screenshot percent = percentSize / 100.0 - res_width = int(round(width * percent)) - res_height = int(round(height * percent)) + res_width = width * percent + res_height = height * percent frame_template = "{}.{{:04}}.{}".format(filepath, ext) frame_template.replace("\\", "/") files = [] @@ -212,7 +212,7 @@ def _render_preview_animation_max_pre_2024( widthCrop = dib_height * renderRatio leftEdge = int((dib_width - widthCrop) / 2.0) tempImage_bmp = rt.bitmap(widthCrop, dib_height) - src_box_value = rt.Box2(0, leftEdge, widthCrop, dib_height) + src_box_value = rt.Box2(leftEdge, 0, widthCrop, dib_height) rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) # copy the bitmap and close it rt.copy(tempImage_bmp, preview_res) From 8f21d653e079662eef75a435ea97b26d101d7567 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 6 Nov 2023 21:32:43 +0800 Subject: [PATCH 1006/1224] use product_types instead of families --- server_addon/core/server/settings/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index d7c7b367b7..0dd9d396ae 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -489,7 +489,7 @@ DEFAULT_TOOLS_VALUES = { "template_name": "publish_online" }, { - "families": [ + "product_types": [ "tycache" ], "hosts": [ From 1d34d5b1ae48ef69e12d30585b03a9352203f498 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 6 Nov 2023 18:04:08 +0100 Subject: [PATCH 1007/1224] use "asset" value instead of name from assetEntity --- .../hosts/maya/plugins/publish/validate_instance_in_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py index 4ded57137c..edfb002278 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py @@ -74,4 +74,4 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin, @staticmethod def get_context_asset(instance): - return instance.context.data["assetEntity"]["name"] + return instance.context.data["asset"] From f2e14e7f4aa4c9d1557e3fdacc05cc564c21d7bb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 6 Nov 2023 18:05:37 +0100 Subject: [PATCH 1008/1224] use asset and project from instance and context data --- openpype/hosts/maya/plugins/publish/collect_review.py | 8 ++++---- .../hosts/maya/plugins/publish/validate_model_name.py | 6 ++++-- .../hosts/maya/plugins/publish/validate_shader_name.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index 586939a3b8..0930da8f27 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -3,7 +3,7 @@ from maya import cmds, mel import pyblish.api from openpype.client import get_subset_by_name -from openpype.pipeline import legacy_io, KnownPublishError +from openpype.pipeline import KnownPublishError from openpype.hosts.maya.api import lib @@ -116,10 +116,10 @@ class CollectReview(pyblish.api.InstancePlugin): instance.data['remove'] = True else: - task = legacy_io.Session["AVALON_TASK"] - legacy_subset_name = task + 'Review' + project_name = instance.context.data["projectName"] asset_doc = instance.context.data['assetEntity'] - project_name = legacy_io.active_project() + task = instance.context.data["task"] + legacy_subset_name = task + 'Review' subset_doc = get_subset_by_name( project_name, legacy_subset_name, diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index f4c1aa39c7..11f59bb439 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -67,13 +67,15 @@ class ValidateModelName(pyblish.api.InstancePlugin, regex = cls.top_level_regex r = re.compile(regex) m = r.match(top_group) + project_name = instance.context.data["projectName"] + current_asset_name = instance.context.data["asset"] if m is None: cls.log.error("invalid name on: {}".format(top_group)) cls.log.error("name doesn't match regex {}".format(regex)) invalid.append(top_group) else: if "asset" in r.groupindex: - if m.group("asset") != legacy_io.Session["AVALON_ASSET"]: + if m.group("asset") != current_asset_name: cls.log.error("Invalid asset name in top level group.") return top_group if "subset" in r.groupindex: @@ -81,7 +83,7 @@ class ValidateModelName(pyblish.api.InstancePlugin, cls.log.error("Invalid subset name in top level group.") return top_group if "project" in r.groupindex: - if m.group("project") != legacy_io.Session["AVALON_PROJECT"]: + if m.group("project") != project_name: cls.log.error("Invalid project name in top level group.") return top_group diff --git a/openpype/hosts/maya/plugins/publish/validate_shader_name.py b/openpype/hosts/maya/plugins/publish/validate_shader_name.py index 36bb2c1fee..d6486dea7f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shader_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_shader_name.py @@ -51,7 +51,7 @@ class ValidateShaderName(pyblish.api.InstancePlugin, descendants = cmds.ls(descendants, noIntermediate=True, long=True) shapes = cmds.ls(descendants, type=["nurbsSurface", "mesh"], long=True) - asset_name = instance.data.get("asset", None) + asset_name = instance.data.get("asset") # Check the number of connected shadingEngines per shape regex_compile = re.compile(cls.regex) From b7bec4b4e4fea704b0706013e76ac03de325ebee Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 6 Nov 2023 18:28:51 +0100 Subject: [PATCH 1009/1224] use correct label for follow workfile version --- server_addon/core/server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index 69a759465e..93d8db964d 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class ValidateBaseModel(BaseSettingsModel): class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = Field( - True, title="Collect Anatomy Instance Data" + True, title="Follow workfile version" ) From efa9b8fe4c1b22af0aa561f6ea9132687b703f5e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Nov 2023 18:39:58 +0100 Subject: [PATCH 1010/1224] Remove unused `instanceToggled` callbacks (#5862) --- openpype/hosts/aftereffects/api/pipeline.py | 10 ---------- openpype/hosts/nuke/api/pipeline.py | 22 --------------------- openpype/hosts/photoshop/api/pipeline.py | 10 ---------- openpype/hosts/tvpaint/api/pipeline.py | 4 ---- 4 files changed, 46 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 8fc7a70dd8..e059f7c272 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -74,11 +74,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", application_launch) @@ -186,11 +181,6 @@ def application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active AfterEffects document. diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index f6ba33f00f..ba4d66ab63 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -129,9 +129,6 @@ class NukeHost( register_event_callback("workio.open_file", check_inventory_versions) register_event_callback("taskChanged", change_context_label) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled) - _install_menu() # add script menu @@ -402,25 +399,6 @@ def add_shortcuts_from_presets(): log.error(e) -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle node passthrough states on instance toggles.""" - - log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( - instance, old_value, new_value)) - - # Whether instances should be passthrough based on new value - - with viewer_update_and_undo_stop(): - n = instance[0] - try: - n["publish"].value() - except ValueError: - n = add_publish_knob(n) - log.info(" `Publish` knob was added to write node..") - - n["publish"].setValue(new_value) - - def containerise(node, name, namespace, diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 56ae2a4c25..4e0dbcad06 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -48,11 +48,6 @@ class PhotoshopHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", on_application_launch) @@ -177,11 +172,6 @@ def on_application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active Photoshop document diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index a84f196f09..c125da1533 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -84,10 +84,6 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(load_dir) register_creator_plugin_path(create_dir) - registered_callbacks = ( - pyblish.api.registered_callbacks().get("instanceToggled") or [] - ) - register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) From 3fdccc886f4d8f785c0d9297264b15a6904d496d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 7 Nov 2023 15:46:12 +0800 Subject: [PATCH 1011/1224] make sure the extraction would be skipped if there is empty list of animated_skeleton --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index 8288bc9329..ad9ca385dc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -42,6 +42,11 @@ 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 + if not out_members: + self.log.debug( + "Top group of animated skeleton not found...skipping extraction") + return + namespace = get_namespace(out_members[0]) relative_out_members = [ strip_namespace(node, namespace) for node in out_members From 69c33f9c8f560c3c92407ae13f471cba36b9790e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 7 Nov 2023 15:52:19 +0800 Subject: [PATCH 1012/1224] hound --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 3 ++- 1 file changed, 2 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 ad9ca385dc..c6f8029e7d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,7 +44,8 @@ class ExtractFBXAnimation(publish.Extractor): # names as existing in the rig workfile if not out_members: self.log.debug( - "Top group of animated skeleton not found...skipping extraction") + "Top group of animated skeleton not found.." + "skipping extraction") return namespace = get_namespace(out_members[0]) From 93101debd87d1d72cbeec69f2131f093f06c27a7 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 7 Nov 2023 10:23:55 +0100 Subject: [PATCH 1013/1224] collect workfile remove unused import and comment --- openpype/hosts/blender/plugins/publish/collect_workfile.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_workfile.py b/openpype/hosts/blender/plugins/publish/collect_workfile.py index e431405e80..01c033084b 100644 --- a/openpype/hosts/blender/plugins/publish/collect_workfile.py +++ b/openpype/hosts/blender/plugins/publish/collect_workfile.py @@ -1,6 +1,5 @@ from pathlib import Path -import bpy from pyblish.api import InstancePlugin, CollectorOrder @@ -30,7 +29,7 @@ class CollectWorkfile(InstancePlugin): { "name": ext.lstrip("."), "ext": ext, - "files": filepath.name, # TODO resources + "files": filepath.name, "stagingDir": filepath.parent, } ], From 7f5986a683299a10d65789cc9ac86362d97262d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 11:43:08 +0100 Subject: [PATCH 1014/1224] fix maya workfile creator --- openpype/hosts/maya/plugins/create/create_workfile.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_workfile.py b/openpype/hosts/maya/plugins/create/create_workfile.py index 7282fc6b8b..198f9c4a36 100644 --- a/openpype/hosts/maya/plugins/create/create_workfile.py +++ b/openpype/hosts/maya/plugins/create/create_workfile.py @@ -72,7 +72,10 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): ) asset_name = get_asset_name_identifier(asset_doc) - current_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name From 1e819a26838570aaf93cf89e23adf5f673413a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 7 Nov 2023 13:28:26 +0100 Subject: [PATCH 1015/1224] Update openpype/hosts/traypublisher/api/editorial.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/traypublisher/api/editorial.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index 2f5e709ffc..ddb369468f 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -321,7 +321,8 @@ class ShotMetadataSolver: # generate hierarchy path from parents hierarchy_path = self._create_hierarchy_path(parents) - + if hierarchy_path: + hierarchy_path = f"/{hierarchy_path}" return shot_name, { "hierarchy": hierarchy_path, "folderPath": f"{hierarchy_path}/{shot_name}", From ce413045130184c6da299d1f3a4a441a550a146d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 13:29:15 +0100 Subject: [PATCH 1016/1224] ignore if passed icon definition is None --- openpype/tools/ayon_utils/widgets/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py index 8bc3b1ea9b..2817b5efc0 100644 --- a/openpype/tools/ayon_utils/widgets/utils.py +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -54,6 +54,8 @@ class _IconsCache: @classmethod def get_icon(cls, icon_def): + if not icon_def: + return None icon_type = icon_def["type"] cache_key = cls._get_cache_key(icon_def) cache = cls._cache.get(cache_key) From 46761a028f6a6c91815f6b8c7705abf706f48380 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 13:31:04 +0100 Subject: [PATCH 1017/1224] traypublisher: editorial better handling hierarchy for folder path --- openpype/hosts/traypublisher/api/editorial.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index ddb369468f..1a83fcecbd 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -322,10 +322,13 @@ class ShotMetadataSolver: # generate hierarchy path from parents hierarchy_path = self._create_hierarchy_path(parents) if hierarchy_path: - hierarchy_path = f"/{hierarchy_path}" + folder_path = f"/{hierarchy_path}/{shot_name}" + else: + folder_path = f"/{shot_name}" + return shot_name, { "hierarchy": hierarchy_path, - "folderPath": f"{hierarchy_path}/{shot_name}", + "folderPath": folder_path, "parents": parents, "tasks": tasks } From 62af7138e11cc11509b91ec2f1ee7cc1995fb10b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 13:38:43 +0100 Subject: [PATCH 1018/1224] traypublisher: editorial ayon attribute only https://github.com/ynput/OpenPype/pull/5873#discussion_r1384655823 --- .../hosts/traypublisher/plugins/create/create_editorial.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 5dc3893697..59f24a2a2b 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -683,13 +683,15 @@ or updating already created. Publishing will create OTIO file. # create creator attributes creator_attributes = { "asset_name": shot_name, - "Folder path": shot_metadata["folderPath"], "Parent hierarchy path": shot_metadata["hierarchy"], "workfile_start_frame": workfile_start_frame, "fps": fps, "handle_start": int(handle_start), "handle_end": int(handle_end) } + if AYON_SERVER_ENABLED: + creator_attributes["folderPath"] = shot_metadata["folderPath"] + creator_attributes.update(timing_data) # create shared new instance data From bf96b15b90e04b0721af853adeb161707ebd5b8c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 13:50:44 +0100 Subject: [PATCH 1019/1224] center publisher window on first show --- openpype/tools/publisher/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 312cf1dd5c..2416763c27 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -15,6 +15,7 @@ from openpype.tools.utils import ( MessageOverlayObject, PixmapLabel, ) +from openpype.tools.utils.lib import center_window from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget @@ -529,6 +530,7 @@ class PublisherWindow(QtWidgets.QDialog): def _on_first_show(self): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + center_window(self) self._reset_on_show = self._reset_on_first_show def _on_show_timer(self): From 414df2370346aabbb555212f8123bbcb35817ea2 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 7 Nov 2023 13:07:26 +0000 Subject: [PATCH 1020/1224] Introduce app_group flag (#5869) --- openpype/cli.py | 7 +++++-- openpype/pype_commands.py | 5 ++++- tests/conftest.py | 10 ++++++++++ tests/lib/testing_classes.py | 9 ++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 7422f32f13..f0fe550a1f 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -282,6 +282,9 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) +@click.option("--app_group", + help="Provide specific app group for test, empty for default", + default=None) @click.option("-t", "--timeout", help="Provide specific timeout value for test case", @@ -294,11 +297,11 @@ def run(script): help="MongoDB for testing.", default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only, mongo_url): + timeout, setup_only, mongo_url, app_group): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url) + mongo_url, app_group) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 071ecfffd2..b5828d3dfe 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -214,7 +214,7 @@ class PypeCommands: def run_tests(self, folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url): + mongo_url, app_group): """ Runs tests from 'folder' @@ -260,6 +260,9 @@ class PypeCommands: if persist: args.extend(["--persist", persist]) + if app_group: + args.extend(["--app_group", app_group]) + if app_variant: args.extend(["--app_variant", app_variant]) diff --git a/tests/conftest.py b/tests/conftest.py index 6e82c9917d..a862030fff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,11 @@ def pytest_addoption(parser): help="True - keep test_db, test_openpype, outputted test files" ) + parser.addoption( + "--app_group", action="store", default=None, + help="Keep empty to use default application or explicit" + ) + parser.addoption( "--app_variant", action="store", default=None, help="Keep empty to locate latest installed variant or explicit" @@ -45,6 +50,11 @@ def persist(request): return request.config.getoption("--persist") +@pytest.fixture(scope="module") +def app_group(request): + return request.config.getoption("--app_group") + + @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 277b332e19..e8e338e434 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -248,19 +248,22 @@ class PublishTest(ModuleUnitTest): SETUP_ONLY = False @pytest.fixture(scope="module") - def app_name(self, app_variant): + def app_name(self, app_variant, app_group): """Returns calculated value for ApplicationManager. Eg.(nuke/12-2)""" from openpype.lib import ApplicationManager app_variant = app_variant or self.APP_VARIANT + app_group = app_group or self.APP_GROUP application_manager = ApplicationManager() if not app_variant: variant = ( application_manager.find_latest_available_variant_for_group( - self.APP_GROUP)) + app_group + ) + ) app_variant = variant.name - yield "{}/{}".format(self.APP_GROUP, app_variant) + yield "{}/{}".format(app_group, app_variant) @pytest.fixture(scope="module") def app_args(self, download_test_data): From 82c3442f6192d2f4e26268a89483e55f4cebfbaa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 14:46:05 +0100 Subject: [PATCH 1021/1224] convert 'ValidateAttributes' settings only if are available --- openpype/settings/ayon_settings.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index a31c8a04e0..b56249bbc2 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -641,13 +641,14 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute # --- Publish (START) --- ayon_publish = ayon_max["publish"] - try: - attributes = json.loads( - ayon_publish["ValidateAttributes"]["attributes"] - ) - except ValueError: - attributes = {} - ayon_publish["ValidateAttributes"]["attributes"] = attributes + if "ValidateAttributes" in ayon_publish: + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes output["max"] = ayon_max From e3c28bd55775ebb0d8b1d412dc7155ba0aa8d152 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 7 Nov 2023 13:46:43 +0000 Subject: [PATCH 1022/1224] [Automated] Release --- CHANGELOG.md | 457 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 459 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7432b33e24..b3daf581ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,463 @@ # Changelog +## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.4...3.17.5) + +### **🆕 New features** + + +
+Fusion: Add USD loader #4896 + +Add an OpenPype managed USD loader (`uLoader`) for Fusion. + + +___ + +
+ + +
+Fusion: Resolution validator #5325 + +Added a resolution validator.The code is from my old PR (https://github.com/ynput/OpenPype/pull/4921) that I closed because the PR also contained a frame range validator that no longer is needed. + + +___ + +
+ + +
+Context Selection tool: Refactor Context tool (for AYON) #5766 + +Context selection tool has AYON variant. + + +___ + +
+ + +
+AYON: Use AYON username for user in template data #5842 + +Use ayon username for template data in AYON mode. + + +___ + +
+ + +
+Testing: app_group flag #5869 + +`app_group` command flag. This is for changing which flavour of the host to launch. In the case of Maya, you can launch Maya and MayaPy, but it can be used for the Nuke family as well.Split from #5644 + + +___ + +
+ +### **🚀 Enhancements** + + +
+Enhancement: Fusion fix saver creation + minor Blender/Fusion logging tweaks #5558 + +- Blender change logs to `debug` level in preparation for new publisher artist facing reports (note that it currently still uses the old publisher) +- Fusion: Create Saver fix redeclaration of default_variants +- Fusion: Fix saver being created in incorrect state without saving directly after create +- Fusion: Allow reset frame range on render family +- Fusion: Tweak logging level for artist-facing report + + +___ + +
+ + +
+Resolve: load clip to timeline at set time #5665 + +It is possible to load clip to correct place on timeline. + + +___ + +
+ + +
+Nuke: Optional Deadline workfile dependency. #5732 + +Adds option to add the workfile as dependency for the Deadline job.Think it used to have something like this, but it disappeared. Usecase is for remote workflow where the Nuke script needs to be synced before the job can start. + + +___ + +
+ + +
+Enhancement/houdini rearrange ayon houdini settings files #5748 + +Rearranging Houdini Settings to be more readable, easier to edit, update settings (include all families/product types)This PR is mainly for Ayon Settings to have more organized files. For Openpype, I'll make sure that each Houdini setting in Ayon has an equivalent in Openpype. +- [x] update Ayon settings, fix typos and remove deprecated settings. +- [x] Sync with Openpype +- [x] Test in Openpype +- [x] Test in Ayon + + +___ + +
+ + +
+Chore: updating create ayon addon script #5822 + +Adding developers environment options. + + +___ + +
+ + +
+Max: Implement Validator for Properties/Attributes Value Check #5824 + +Add optional validator which can check if the property attributes are valid in Max + + +___ + +
+ + +
+Nuke: Remove unused 'get_render_path' function #5826 + +Remove unused function `get_render_path` from nuke integration. + + +___ + +
+ + +
+Chore: Limit current context template data function #5845 + +Current implementation of `get_current_context_template_data` does return the same values as base template data function `get_template_data`. + + +___ + +
+ + +
+Max: Make sure Collect Render not ignoring instance asset #5847 + +- Make sure Collect Render is not always using asset from context. +- Make sure Scene version being collected +- Clean up unnecessary uses of code in the collector. + + +___ + +
+ + +
+Ftrack: Events are not processed if project is not available in OpenPype #5853 + +Events that happened on project which is not in OpenPype is not processed. + + +___ + +
+ + +
+Nuke: Add Nuke 11.0 as default setting #5855 + +Found I needed Nuke 11.0 in the default settings to help with unit testing. + + +___ + +
+ + +
+TVPaint: Code cleanup #5857 + +Removed unused import. Use `AYON` label in ayon mode. Removed unused data in publish context `"previous_context"`. + + +___ + +
+ + +
+AYON settings: Use correct label for follow workfile version #5874 + +Follow workfile version label was marked as Collect Anatomy Instance Data label. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Nuke: Fix workfile template builder so representations get loaded next to each other #5061 + +Refactor when the cleanup of the placeholder happens for the cases where multiple representations are loaded by a single placeholder.The existing code didn't take into account the case where a template placeholder can load multiple representations so it was trying to do the cleanup of the placeholder node and the re-arrangement of the imported nodes too early. I assume this was designed only for the cases where a single representation can load multiple nodes. + + +___ + +
+ + +
+Nuke: Dont update node name on update #5704 + +When updating `Image` containers the code is trying to set the name of the node. This results in a warning message from Nuke shown below;Suggesting to not change the node name when updating. + + +___ + +
+ + +
+UIDefLabel can be unique #5827 + +`UILabelDef` have implemented comparison and uniqueness. + + +___ + +
+ + +
+AYON: Skip kitsu module when creating ayon addons #5828 + +Create AYON packages is skipping kitsu module in creation of modules/addons and kitsu module is not loaded from modules on start. The addon already has it's repository https://github.com/ynput/ayon-kitsu. + + +___ + +
+ + +
+Bugfix: Collect Rendered Files only collecting first instance #5832 + +Collect all instances from the metadata file - don't return on first instance iteration. + + +___ + +
+ + +
+Houdini: set frame range for the created composite ROP #5833 + +Quick bug fix for created composite ROP, set its frame range to the frame range of the playbar. + + +___ + +
+ + +
+Fix registering launcher actions from OpenPypeModules #5843 + +Fix typo `actions_dir` -> `path` to fix register launcher actions fromm OpenPypeModule + + +___ + +
+ + +
+Bugfix in houdini shelves manager and beautify settings #5844 + +This PR fixes the problem in this PR https://github.com/ynput/OpenPype/issues/5457 by using the right function to load a pre-made houdini `.shelf` fileAlso, it beautifies houdini shelves settings to provide better guidance for users which helps with other issue https://github.com/ynput/OpenPype/issues/5458 , Rather adding default shelf and set names, I'll educate users how to use the tool correctly.Users now are able to select between the two options.| OpenPype | Ayon || -- | -- || | | + + +___ + +
+ + +
+Blender: Fix missing Grease Pencils in review #5848 + +Fix Grease Pencil missing in review when isolating objects. + + +___ + +
+ + +
+Blender: Fix Render Settings in Ayon #5849 + +Fix Render Settings in Ayon for Blender. + + +___ + +
+ + +
+Bugfix: houdini tab menu working as expected #5850 + +This PR:Tab menu name changes to Ayon when using ayon get_network_categories is checked in all creator plugins. | Product | Network Category | | -- | -- | | Alembic camera | rop, obj | | Arnold Ass | rop | | Arnold ROP | rop | | Bgeo | rop, sop | | composite sequence | cop2, rop | | hda | obj | | Karma ROP | rop | | Mantra ROP | rop | | ABC | rop, sop | | RS proxy | rop, sop| | RS ROP | rop | | Review | rop | | Static mesh | rop, obj, sop | | USD | lop, rop | | USD Render | rop | | VDB | rop, obj, sop | | V Ray | rop | + + +___ + +
+ + +
+Bigfix: Houdini skip frame_range_validator if node has no 'trange' parameter #5851 + +I faced a bug when publishing HDA instance as it has no `trange` parameter. As this PR title says : skip frame_range_validator if node has no 'trange' parameter + + +___ + +
+ + +
+Bugfix: houdini image sequence loading and missing frames #5852 + +I made this PR in to fix issues mentioned here https://github.com/ynput/OpenPype/pull/5833#issuecomment-1789207727in short: +- image load doesn't work +- publisher only publish one frame + + +___ + +
+ + +
+Nuke: loaders' containers updating as nodes #5854 + +Nuke loaded containers are updating correctly even they have been duplicating of originally loaded nodes. This had previously been removed duplicated nodes. + + +___ + +
+ + +
+deadline: settings are not blocking extension input #5864 + +Settings are not blocking user input. + + +___ + +
+ + +
+Blender: Fix loading of blend layouts #5866 + +Fix a problem with loading blend layouts. + + +___ + +
+ + +
+AYON: Launcher refresh issues #5867 + +Fixed refresh of projects issue in launcher tool. And renamed Qt models to contain `Qt` in their name (it was really hard to find out where were used). It is not possible to click on disabled item in launcher's projects view. + + +___ + +
+ + +
+Fix the Wrong key words for tycache workfile template settings in AYON #5870 + +Fix the wrong key words for the tycache workfile template settings in AYON(i.e. Instead of families, product_types should be used) + + +___ + +
+ + +
+AYON tools: Handle empty icon definition #5876 + +Ignore if passed icon definition is `None`. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Houdini: Remove on instance toggled callback #5860 + +Remove on instance toggled callback which isn't relevant to the new publisher + + +___ + +
+ + +
+Chore: Remove unused `instanceToggled` callbacks #5862 + +The `instanceToggled` callbacks should be irrelevant for new publisher. + + +___ + +
+ + + + ## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) diff --git a/openpype/version.py b/openpype/version.py index c6ebd65e9c..9832c77291 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.3" +__version__ = "3.17.5" diff --git a/pyproject.toml b/pyproject.toml index 633dafece1..c6f4880cdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.4" # OpenPype +version = "3.17.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 246a4af3ef2185b2ad924bd89c8b4f2bd4404678 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Nov 2023 13:47:43 +0000 Subject: [PATCH 1023/1224] 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 7a1fe9d83e..bdfc2ad46f 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.5 - 3.17.5-nightly.3 - 3.17.5-nightly.2 - 3.17.5-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.1-nightly.6 - 3.15.1-nightly.5 - 3.15.1-nightly.4 - - 3.15.1-nightly.3 validations: required: true - type: dropdown From 7aa5b09ef26dd2da96cccb0eb28d79a6e3d20844 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 14:56:46 +0100 Subject: [PATCH 1024/1224] improving code --- openpype/hosts/traypublisher/api/editorial.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index 1a83fcecbd..613f1de768 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -53,11 +53,11 @@ class ShotMetadataSolver: try: # format to new shot name return shot_rename_template.format(**data) - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct:: \n\n" f"From template string {shot_rename_template} > " - f"`{_E}` has no equivalent in \n" + f"`{_error}` has no equivalent in \n" f"{list(data.keys())} input formatting keys!" )) @@ -100,7 +100,7 @@ class ShotMetadataSolver: "at your project settings..." )) - # QUESTION:how to refactory `match[-1]` to some better way? + # QUESTION:how to refactor `match[-1]` to some better way? output_data[token_key] = match[-1] return output_data @@ -130,10 +130,10 @@ class ShotMetadataSolver: parent_token["name"]: parent_token["value"].format(**data) for parent_token in hierarchy_parents } - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n" - f"`{_E}` has no equivalent in \n{list(data.keys())}" + f"`{_error}` has no equivalent in \n{list(data.keys())}" )) _parent_tokens_type = { @@ -147,10 +147,10 @@ class ShotMetadataSolver: try: parent_name = _parent.format( **_parent_tokens_formatting_data) - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n\n" - f"`{_E}` from template string " + f"`{_error}` from template string " f"{shot_hierarchy['parents_path']}, " f" has no equivalent in \n" f"{list(_parent_tokens_formatting_data.keys())} parents" From 638c1c65e78cdf4fc9327820a265878f0a5e13a6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 14:57:44 +0100 Subject: [PATCH 1025/1224] ayon distributed folder path from create to publish --- .../plugins/create/create_editorial.py | 69 +++++++++++-------- .../plugins/publish/collect_shot_instances.py | 8 ++- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 59f24a2a2b..8c5083bcb2 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -102,14 +102,23 @@ class EditorialShotInstanceCreator(EditorialClipInstanceCreatorBase): label = "Editorial Shot" def get_instance_attr_defs(self): - attr_defs = [ - TextDef( - "asset_name", - label="Asset name", + instance_attributes = [] + if AYON_SERVER_ENABLED: + instance_attributes.append( + TextDef( + "folderPath", + label="Folder path" + ) ) - ] - attr_defs.extend(CLIP_ATTR_DEFS) - return attr_defs + else: + instance_attributes.append( + TextDef( + "shotName", + label="Shot name" + ) + ) + instance_attributes.extend(CLIP_ATTR_DEFS) + return instance_attributes class EditorialPlateInstanceCreator(EditorialClipInstanceCreatorBase): @@ -216,10 +225,7 @@ or updating already created. Publishing will create OTIO file. ] } # Create otio editorial instance - if AYON_SERVER_ENABLED: - asset_name = instance_data.pop("folderPath") - else: - asset_name = instance_data["asset"] + asset_name = instance_data["asset"] asset_doc = get_asset_by_name(self.project_name, asset_name) @@ -671,7 +677,10 @@ or updating already created. Publishing will create OTIO file. } ) - self._validate_name_uniqueness(shot_name) + # It should be validated only in openpype since we are supporting + # publishing to AYON with folder path and uniqueness is not an issue + if not AYON_SERVER_ENABLED: + self._validate_name_uniqueness(shot_name) timing_data = self._get_timing_data( otio_clip, @@ -682,36 +691,42 @@ or updating already created. Publishing will create OTIO file. # create creator attributes creator_attributes = { - "asset_name": shot_name, - "Parent hierarchy path": shot_metadata["hierarchy"], + "workfile_start_frame": workfile_start_frame, "fps": fps, "handle_start": int(handle_start), "handle_end": int(handle_end) } - if AYON_SERVER_ENABLED: - creator_attributes["folderPath"] = shot_metadata["folderPath"] - + # add timing data creator_attributes.update(timing_data) - # create shared new instance data + # create base instance data base_instance_data = { "shotName": shot_name, "variant": variant_name, - - # HACK: just for temporal bug workaround - # TODO: should loockup shot name for update - "asset": parent_asset_name, "task": "", - "newAssetPublishing": True, - - # parent time properties "trackStartFrame": track_start_frame, "timelineOffset": timeline_offset, - # creator_attributes - "creator_attributes": creator_attributes } + # update base instance data with context data + # and also update creator attributes with context data + if AYON_SERVER_ENABLED: + # TODO: this is here just to be able to publish + # to AYON with folder path + creator_attributes["folderPath"] = shot_metadata.pop("folderPath") + base_instance_data["folderPath"] = parent_asset_name + else: + creator_attributes.update({ + "shotName": shot_name, + "Parent hierarchy path": shot_metadata["hierarchy"] + }) + + base_instance_data["asset"] = parent_asset_name + + + # add creator attributes to shared instance data + base_instance_data["creator_attributes"] = creator_attributes # add hierarchy shot metadata base_instance_data.update(shot_metadata) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py index b08397caf7..65dd35782f 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py @@ -2,6 +2,8 @@ from pprint import pformat import pyblish.api import opentimelineio as otio +from openpype import AYON_SERVER_ENABLED + class CollectShotInstance(pyblish.api.InstancePlugin): """ Collect shot instances @@ -119,7 +121,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): frame_end = _cr_attrs["frameEnd"] frame_dur = frame_end - frame_start - return { + data = { "fps": float(_cr_attrs["fps"]), "handleStart": _cr_attrs["handle_start"], "handleEnd": _cr_attrs["handle_end"], @@ -132,6 +134,10 @@ class CollectShotInstance(pyblish.api.InstancePlugin): "sourceOut": _cr_attrs["sourceOut"], "workfileFrameStart": workfile_start_frame } + if AYON_SERVER_ENABLED: + data["asset"] = _cr_attrs["asset"] + + return data def _solve_hierarchy_context(self, instance): """ Adding hierarchy data to context shared data. From cf356e7ecd66521f91996024bac4e911a4ce09ac Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Tue, 7 Nov 2023 16:00:50 +0100 Subject: [PATCH 1026/1224] add create render identifier --- openpype/hosts/blender/plugins/create/create_render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py index 45570f3491..e036ae7df3 100644 --- a/openpype/hosts/blender/plugins/create/create_render.py +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -13,6 +13,7 @@ from openpype.hosts.blender.api.pipeline import ( class CreateRenderlayer(plugin.BaseCreator): """Single baked camera""" + identifier = "io.openpype.creators.blender.render" name = "renderingMain" label = "Render" family = "render" @@ -28,8 +29,7 @@ class CreateRenderlayer(plugin.BaseCreator): bpy.context.scene.collection.children.link(instances) # Create instance object - asset = instance_data.get("asset") - name = plugin.asset_name(asset, subset_name) + name = plugin.asset_name(instance_data.get("asset"), subset_name) asset_group = bpy.data.collections.new(name=name) try: From 44578b2121f1eee9fa6daca3e72dac047f41ebf2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 16:15:16 +0100 Subject: [PATCH 1027/1224] traypublisher: editorial collector asset to instance with ayon exception for folderPath --- .../traypublisher/plugins/publish/collect_shot_instances.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py index 65dd35782f..0b7e022658 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py @@ -135,7 +135,9 @@ class CollectShotInstance(pyblish.api.InstancePlugin): "workfileFrameStart": workfile_start_frame } if AYON_SERVER_ENABLED: - data["asset"] = _cr_attrs["asset"] + data["asset"] = _cr_attrs["folderPath"] + else: + data["asset"] = _cr_attrs["shotName"] return data From f3370c0229da5ff9c323a7277f8711122b25a4b7 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 7 Nov 2023 15:21:10 +0000 Subject: [PATCH 1028/1224] Changed how extractors name the output files --- openpype/hosts/blender/plugins/publish/extract_abc.py | 9 ++++++--- .../blender/plugins/publish/extract_abc_animation.py | 10 +++++++--- .../hosts/blender/plugins/publish/extract_blend.py | 9 ++++++--- .../plugins/publish/extract_blend_animation.py | 9 ++++++--- .../blender/plugins/publish/extract_camera_abc.py | 9 ++++++--- .../blender/plugins/publish/extract_camera_fbx.py | 9 ++++++--- openpype/hosts/blender/plugins/publish/extract_fbx.py | 9 ++++++--- .../blender/plugins/publish/extract_fbx_animation.py | 11 +++++++---- .../hosts/blender/plugins/publish/extract_layout.py | 10 +++++++--- .../blender/plugins/publish/extract_playblast.py | 5 ++++- .../blender/plugins/publish/extract_thumbnail.py | 5 ++++- 11 files changed, 65 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index b17d7cc6e4..59035d8f61 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -17,7 +17,10 @@ class ExtractABC(publish.Extractor): def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -59,8 +62,8 @@ class ExtractABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") class ExtractModelABC(ExtractABC): diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index 6866b05fea..0ac6f12de5 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -17,7 +17,11 @@ class ExtractAnimationABC(publish.Extractor): def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" + filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -66,5 +70,5 @@ class ExtractAnimationABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index c8eeef7fd7..0a9fb74f7b 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -17,7 +17,10 @@ class ExtractBlend(publish.Extractor): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.blend" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -52,5 +55,5 @@ class ExtractBlend(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 661cecce81..3d36ee7ec3 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -17,7 +17,10 @@ class ExtractBlendAnimation(publish.Extractor): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.blend" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -50,5 +53,5 @@ class ExtractBlendAnimation(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index 5916564ac0..b6b38b41ff 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -18,7 +18,10 @@ class ExtractCameraABC(publish.Extractor): def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -64,5 +67,5 @@ class ExtractCameraABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py index a541f5b375..be9f178d1b 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py @@ -17,7 +17,10 @@ class ExtractCamera(publish.Extractor): def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -73,5 +76,5 @@ class ExtractCamera(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index f2ce117dcd..c21dc35ff6 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -18,7 +18,10 @@ class ExtractFBX(publish.Extractor): def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction @@ -84,5 +87,5 @@ class ExtractFBX(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 5fe5931e65..ed4e7ecc6a 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -86,7 +86,10 @@ class ExtractAnimationFBX(publish.Extractor): asset_group.select_set(True) armature.select_set(True) - fbx_filename = f"{instance.name}_{armature.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + fbx_filename = f"{instance_name}_{armature.name}.fbx" filepath = os.path.join(stagingdir, fbx_filename) override = plugin.create_blender_context( @@ -119,7 +122,7 @@ class ExtractAnimationFBX(publish.Extractor): pair[1].user_clear() bpy.data.actions.remove(pair[1]) - json_filename = f"{instance.name}.json" + json_filename = f"{instance_name}.json" json_path = os.path.join(stagingdir, json_filename) json_dict = { @@ -158,5 +161,5 @@ class ExtractAnimationFBX(publish.Extractor): instance.data["representations"].append(fbx_representation) instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '{}' to: {}".format( - instance.name, fbx_representation)) + self.log.info( + f"Extracted instance '{instance_name}' to: {fbx_representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index 05f86b8370..8e820ee84e 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -212,7 +212,11 @@ class ExtractLayout(publish.Extractor): json_data.append(json_element) - json_filename = "{}.json".format(instance.name) + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + json_filename = f"{instance_name}.json" + json_path = os.path.join(stagingdir, json_filename) with open(json_path, "w+") as file: @@ -245,5 +249,5 @@ class ExtractLayout(publish.Extractor): } instance.data["representations"].append(fbx_representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, json_representation) + self.log.info( + f"Extracted instance '{instance_name}' to: {json_representation}") diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py index b0099cce85..805aacc5f4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_playblast.py +++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py @@ -50,7 +50,10 @@ class ExtractPlayblast(publish.Extractor): # get output path stagingdir = self.staging_dir(instance) - filename = instance.name + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + filename = f"{asset_name}_{subset}" + path = os.path.join(stagingdir, filename) self.log.debug(f"Outputting images to {path}") diff --git a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py index 52e5d98fc4..e8a9c68dd1 100644 --- a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py @@ -27,7 +27,10 @@ class ExtractThumbnail(publish.Extractor): self.log.debug("Extracting capture..") stagingdir = self.staging_dir(instance) - filename = instance.name + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + filename = f"{asset_name}_{subset}" + path = os.path.join(stagingdir, filename) self.log.debug(f"Outputting images to {path}") From f7d76617c0ea5635f9ae8ad0e6a18454da24c2be Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 7 Nov 2023 15:51:54 +0000 Subject: [PATCH 1029/1224] Testing: Validate Maya Logs (#5775) * Working version * Improve launched app communication * Move imports to methods. * Update tests/integration/hosts/maya/test_publish_in_maya.py Co-authored-by: Roy Nieterau * Collect errors from process * fix startup scripts arguments * Update openpype/lib/applications.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Fix application polling * Docstring * Revert stdout and stderr * Revert subprocess.PIPE * Added missed imports If we are moving these because of testing, lets move all of them --------- Co-authored-by: Roy Nieterau Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: kalisp --- openpype/hosts/maya/api/lib_rendersettings.py | 6 ++-- .../collect_deadline_server_from_instance.py | 5 ++-- .../publish/submit_blender_deadline.py | 5 ++-- .../publish/submit_houdini_remote_publish.py | 3 +- .../publish/submit_houdini_render_deadline.py | 5 ++-- .../plugins/publish/submit_max_deadline.py | 14 +++++---- .../plugins/publish/submit_maya_deadline.py | 8 ++--- .../submit_maya_remote_publish_deadline.py | 5 ++-- .../plugins/publish/submit_nuke_deadline.py | 5 ++-- .../publish/collect_otio_frame_ranges.py | 18 +++++++----- .../plugins/publish/collect_otio_review.py | 7 +++-- .../publish/collect_otio_subset_resources.py | 17 ++++++----- .../publish/extract_otio_audio_tracks.py | 8 +++-- openpype/plugins/publish/extract_otio_file.py | 5 +++- .../plugins/publish/extract_otio_review.py | 24 ++++++++++----- .../publish/extract_otio_trimming_video.py | 3 +- tests/integration/hosts/maya/lib.py | 7 ++++- .../hosts/maya/test_publish_in_maya.py | 29 +++++++++++++++++++ tests/lib/testing_classes.py | 3 +- 19 files changed, 121 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index 42cf29d0a7..20264c2cdf 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- """Class for handling Render Settings.""" -from maya import cmds # noqa -import maya.mel as mel import six import sys @@ -63,6 +61,10 @@ class RenderSettings(object): def set_default_renderer_settings(self, renderer=None): """Set basic settings based on renderer.""" + # Not all hosts can import this module. + from maya import cmds + import maya.mel as mel + if not renderer: renderer = cmds.getAttr( 'defaultRenderGlobals.currentRenderer').lower() diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 9b4f89c129..1d3dad769f 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -5,8 +5,6 @@ This is resolving index of server lists stored in `deadlineServers` instance attribute or using default server if that attribute doesn't exists. """ -from maya import cmds - import pyblish.api from openpype.pipeline.publish import KnownPublishError @@ -44,7 +42,8 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): str: Selected Deadline Webservice URL. """ - + # Not all hosts can import this module. + from maya import cmds deadline_settings = ( render_instance.context.data ["system_settings"] diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 4a7497b075..094f2b1821 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -6,8 +6,6 @@ import getpass import attr from datetime import datetime -import bpy - from openpype.lib import is_running_from_build from openpype.pipeline import legacy_io from openpype.pipeline.farm.tools import iter_expected_files @@ -142,6 +140,9 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import bpy + plugin_info = BlenderPluginInfo( SceneFile=self.scene_path, Version=bpy.app.version_string, diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 39c0c3afe4..0bee42c4cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -3,7 +3,6 @@ import json from datetime import datetime import requests -import hou import pyblish.api @@ -31,6 +30,8 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): targets = ["deadline"] def process(self, context): + # Not all hosts can import this module. + import hou # Ensure no errors so far assert all( diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 6f885c578a..abc650204b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -1,9 +1,8 @@ -import hou - import os import attr import getpass from datetime import datetime + import pyblish.api from openpype.pipeline import legacy_io @@ -119,6 +118,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import hou instance = self._instance context = instance.context diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 073da3019a..23d4183132 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -1,8 +1,8 @@ import os import getpass import copy - import attr + from openpype.lib import ( TextDef, BoolDef, @@ -15,11 +15,6 @@ from openpype.pipeline import ( from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) -from openpype.hosts.max.api.lib import ( - get_current_renderer, - get_multipass_setting -) -from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import is_running_from_build @@ -191,6 +186,13 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): + # Not all hosts can import these modules. + from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting + ) + from openpype.hosts.max.api.lib_rendersettings import RenderSettings + instance = self._instance job_info = copy.deepcopy(self.job_info) plugin_info = copy.deepcopy(self.plugin_info) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 7775191b12..7d532923ff 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -28,8 +28,6 @@ from collections import OrderedDict import attr -from maya import cmds - from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin @@ -246,6 +244,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info def get_plugin_info(self): + # Not all hosts can import this module. + from maya import cmds instance = self._instance context = instance.context @@ -288,7 +288,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return plugin_payload def process_submission(self): - + from maya import cmds instance = self._instance filepath = self.scene_path # publish if `use_publish` else workfile @@ -675,7 +675,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, str """ - + from maya import cmds # "vrayscene//_/" vray_settings = cmds.ls(type="VRaySettingsNode") node = vray_settings[0] diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 0d23f44333..41a2a64ab5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -2,8 +2,6 @@ import os import attr from datetime import datetime -from maya import cmds - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests @@ -127,7 +125,8 @@ class MayaSubmitRemotePublishDeadline( job_info.EnvironmentKeyValue[key] = value def get_plugin_info(self): - + # Not all hosts can import this module. + from maya import cmds scene = self._instance.context.data["currentFile"] plugin_info = MayaPluginInfo() diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 0e57c54959..fb3ab2710d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -7,8 +7,6 @@ from datetime import datetime import requests import pyblish.api -import nuke - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( @@ -498,6 +496,9 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, Returning: list: captured groups list """ + # Not all hosts can import this module. + import nuke + captured_groups = [] for lg_name, list_node_class in self.limit_groups.items(): for node_class in list_node_class: diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index 9a68b6e43d..4b130b0e03 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -5,15 +5,9 @@ Requires: masterLayer -> instance data attribute otioClipRange -> instance data attribute """ -# import os -import opentimelineio as otio -import pyblish.api from pprint import pformat -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles -) + +import pyblish.api class CollectOtioFrameRanges(pyblish.api.InstancePlugin): @@ -27,6 +21,14 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles + ) + # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index f0157282a1..0e4d596213 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -11,10 +11,10 @@ Provides: instance -> families (adding ["review", "ftrack"]) """ -import opentimelineio as otio -import pyblish.api from pprint import pformat +import pyblish.api + class CollectOtioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" @@ -25,6 +25,9 @@ class CollectOtioReview(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + # get basic variables otio_review_clips = [] otio_timeline = instance.context.data["otioTimeline"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index f659791d95..739f5bb726 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -6,18 +6,15 @@ Provides: instance -> otioReviewClips """ import os + import clique -import opentimelineio as otio import pyblish.api -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - range_from_frames, - make_sequence_collection -) + from openpype.pipeline.publish import ( get_publish_template_name ) + class CollectOtioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" @@ -26,8 +23,14 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): families = ["clip"] hosts = ["resolve", "hiero", "flame"] - def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + range_from_frames, + make_sequence_collection + ) if "audio" in instance.data["family"]: return diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 4f17731452..4b73321f02 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -1,11 +1,12 @@ import os +import tempfile + import pyblish + from openpype.lib import ( get_ffmpeg_tool_args, run_subprocess ) -import tempfile -import opentimelineio as otio class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -155,6 +156,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): Returns: list: list of audio clip dictionaries """ + # Not all hosts can import this module. + import opentimelineio as otio + output = [] # go trough all audio tracks for otio_track in otio_timeline.tracks: diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index 1a6a82117d..7f1cac33d7 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -1,6 +1,6 @@ import os + import pyblish.api -import opentimelineio as otio from openpype.pipeline import publish @@ -16,6 +16,9 @@ class ExtractOTIOFile(publish.Extractor): hosts = ["resolve", "hiero", "traypublisher"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + if not instance.context.data.get("otioTimeline"): return # create representation data diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 699207df8a..ad4c807091 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -15,8 +15,8 @@ Provides: """ import os + import clique -import opentimelineio as otio from pyblish import api from openpype.lib import ( @@ -24,13 +24,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import ( - otio_range_to_frame_range, - trim_media_range, - range_from_frames, - frames_to_seconds, - make_sequence_collection -) class ExtractOTIOReview(publish.Extractor): @@ -62,6 +55,13 @@ class ExtractOTIOReview(publish.Extractor): output_ext = ".jpg" def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + otio_range_to_frame_range, + make_sequence_collection + ) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -281,6 +281,12 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import these modules. + from openpype.pipeline.editorial import ( + trim_media_range, + range_from_frames + ) + avl_start = int(avl_range.start_time.value) src_start = int(avl_start + start) avl_durtation = int(avl_range.duration.value) @@ -338,6 +344,8 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path and frame start to destination output_path, out_frame_start = self._get_ffmpeg_output() diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 67ff6c538c..2020fcde93 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -15,7 +15,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import frames_to_seconds class ExtractOTIOTrimmingVideo(publish.Extractor): @@ -75,6 +74,8 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): otio_range (opentime.TimeRange): range to trim to """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path to destination output_path = self._get_ffmpeg_output(input_file_path) diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index f27d516605..04ddb765a4 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -33,7 +33,7 @@ class MayaHostFixtures(HostFixtures): yield dest_path @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session): + def startup_scripts(self, monkeypatch_session, download_test_data): """Points Maya to userSetup file from input data""" startup_path = os.path.join( os.path.dirname(__file__), "input", "startup" @@ -44,6 +44,11 @@ class MayaHostFixtures(HostFixtures): "{}{}{}".format(startup_path, os.pathsep, original_pythonpath) ) + monkeypatch_session.setenv( + "MAYA_CMD_FILE_OUTPUT", + os.path.join(download_test_data, "output.log") + ) + @pytest.fixture(scope="module") def skip_compare_folders(self): yield [] diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py index b7ee228aae..be8c74e0b8 100644 --- a/tests/integration/hosts/maya/test_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_publish_in_maya.py @@ -1,3 +1,6 @@ +import re +import os + from tests.lib.assert_classes import DBAssert from tests.integration.hosts.maya.lib import MayaLocalPublishTestClass @@ -35,6 +38,32 @@ class TestPublishInMaya(MayaLocalPublishTestClass): TIMEOUT = 120 # publish timeout + def test_publish( + self, + dbcon, + publish_finished, + download_test_data + ): + """Testing Pyblish and Python logs within Maya.""" + + # All maya output via MAYA_CMD_FILE_OUTPUT env var during test run + logging_path = os.path.join(download_test_data, "output.log") + with open(logging_path, "r") as f: + logging_output = f.read() + + print(("-" * 50) + "LOGGING" + ("-" * 50)) + print(logging_output) + + # Check for pyblish errors. + error_regex = r"pyblish \(ERROR\)((.|\n)*?)((pyblish \())" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + + # Check for python errors. + error_regex = r"// Error((.|\n)*)" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index e8e338e434..3b0611e2a0 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -10,6 +10,7 @@ import glob import platform import requests import re +import time from tests.lib.db_handler import DBHandler from tests.lib.file_handler import RemoteFileHandler @@ -334,7 +335,7 @@ class PublishTest(ModuleUnitTest): print("Creating only setup for test, not launching app") yield False return - import time + time_start = time.time() timeout = timeout or self.TIMEOUT timeout = float(timeout) From 63af150dd8f08b280bb6cb1ebe918dd4025e50a5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:22:02 +0100 Subject: [PATCH 1030/1224] confirm of instance context changes reset origin of input fields --- openpype/tools/publisher/widgets/widgets.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1bbe73381f..77ebc3f0bb 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -579,6 +579,10 @@ class AssetsField(BaseClickableFrame): """Change to asset names set with last `set_selected_items` call.""" self.set_selected_items(self._origin_value) + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + class TasksComboboxProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): @@ -785,6 +789,18 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) + def confirm_value(self): + new_task_name = self._selected_items[0] + origin_value = copy.deepcopy(self._origin_value) + new_origin_value = [ + (asset_name, new_task_name) + for (asset_name, task_name) in origin_value + ] + + self._origin_value = new_origin_value + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + def set_selected_items(self, asset_task_combinations=None): """Set items for selected instances. @@ -919,6 +935,10 @@ class VariantInputWidget(PlaceholderLineEdit): """Change text of multiselection.""" self._multiselection_text = text + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + def _set_is_valid(self, valid): if valid == self._is_valid: return @@ -1210,6 +1230,15 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self._set_btns_enabled(False) self._set_btns_visible(invalid_tasks) + if variant_value is not None: + self.variant_input.confirm_value() + + if asset_name is not None: + self.asset_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value() + self.instance_context_changed.emit() def _on_cancel(self): From e32263916c4856d3d65c4b1c31aeeeaf3047f018 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 17:26:29 +0100 Subject: [PATCH 1031/1224] traypublisher: label of instances with folder path --- .../traypublisher/plugins/create/create_editorial.py | 12 ++++++++---- .../plugins/publish/collect_shot_instances.py | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 8c5083bcb2..128010cef9 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -606,19 +606,23 @@ or updating already created. Publishing will create OTIO file. Returns: str: label string """ - shot_name = instance_data["shotName"] + if AYON_SERVER_ENABLED: + asset_name = instance_data["creator_attributes"]["folderPath"] + else: + asset_name = instance_data["creator_attributes"]["shotName"] + variant_name = instance_data["variant"] family = preset["family"] - # get variant name from preset or from inharitance + # get variant name from preset or from inheritance _variant_name = preset.get("variant") or variant_name # subset name subset_name = "{}{}".format( family, _variant_name.capitalize() ) - label = "{}_{}".format( - shot_name, + label = "{} {}".format( + asset_name, subset_name ) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py index 0b7e022658..e00ac64244 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py @@ -155,7 +155,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): else {} ) - name = instance.data["asset"] + asset_name = instance.data["asset"] # get handles handle_start = int(instance.data["handleStart"]) @@ -177,7 +177,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): parents = instance.data.get('parents', []) - actual = {name: in_info} + actual = {asset_name: in_info} for parent in reversed(parents): parent_name = parent["entity_name"] From c6d81edc346bf233c65f8c96e69597afcd1f2c6e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 7 Nov 2023 17:28:37 +0100 Subject: [PATCH 1032/1224] ayon settings: trypublisher editorial add task model conversion --- openpype/settings/ayon_settings.py | 6 +++++- .../server/settings/editorial_creators.py | 16 +++++++--------- server_addon/traypublisher/server/version.py | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 5eb68e3972..efad3ee27b 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1021,10 +1021,14 @@ def _convert_traypublisher_project_settings(ayon_settings, output): item["family"] = item.pop("product_type") shot_add_tasks = ayon_editorial_simple["shot_add_tasks"] + + # TODO: backward compatibility and remove in future if isinstance(shot_add_tasks, dict): shot_add_tasks = [] + + # aggregate shot_add_tasks items new_shot_add_tasks = { - item["name"]: item["task_type"] + item["name"]: {"type": item["task_type"]} for item in shot_add_tasks } ayon_editorial_simple["shot_add_tasks"] = new_shot_add_tasks diff --git a/server_addon/traypublisher/server/settings/editorial_creators.py b/server_addon/traypublisher/server/settings/editorial_creators.py index 4111f22576..ac0ff0afc7 100644 --- a/server_addon/traypublisher/server/settings/editorial_creators.py +++ b/server_addon/traypublisher/server/settings/editorial_creators.py @@ -5,19 +5,17 @@ from ayon_server.settings import BaseSettingsModel, task_types_enum class ClipNameTokenizerItem(BaseSettingsModel): _layout = "expanded" - # TODO was 'dict-modifiable', is list of dicts now, must be fixed in code - name: str = Field("#TODO", title="Tokenizer name") + name: str = Field("", title="Tokenizer name") regex: str = Field("", title="Tokenizer regex") class ShotAddTasksItem(BaseSettingsModel): _layout = "expanded" - # TODO was 'dict-modifiable', is list of dicts now, must be fixed in code name: str = Field('', title="Key") - task_type: list[str] = Field( + task_type: str = Field( title="Task type", - default_factory=list, - enum_resolver=task_types_enum) + enum_resolver=task_types_enum + ) class ShotRenameSubmodel(BaseSettingsModel): @@ -54,7 +52,7 @@ class TokenToParentConvertorItem(BaseSettingsModel): ) -class ShotHierchySubmodel(BaseSettingsModel): +class ShotHierarchySubmodel(BaseSettingsModel): enabled: bool = True parents_path: str = Field( "", @@ -102,9 +100,9 @@ class EditorialSimpleCreatorPlugin(BaseSettingsModel): title="Shot Rename", default_factory=ShotRenameSubmodel ) - shot_hierarchy: ShotHierchySubmodel = Field( + shot_hierarchy: ShotHierarchySubmodel = Field( title="Shot Hierarchy", - default_factory=ShotHierchySubmodel + default_factory=ShotHierarchySubmodel ) shot_add_tasks: list[ShotAddTasksItem] = Field( title="Add tasks to shot", diff --git a/server_addon/traypublisher/server/version.py b/server_addon/traypublisher/server/version.py index df0c92f1e2..e57ad00718 100644 --- a/server_addon/traypublisher/server/version.py +++ b/server_addon/traypublisher/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.2" +__version__ = "0.1.3" From e4ed21623a57618fa6889750c5cf97ec9cc1872d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:35:48 +0100 Subject: [PATCH 1033/1224] fix task combinations --- openpype/tools/publisher/widgets/widgets.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 77ebc3f0bb..9b31697749 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -789,15 +789,12 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) - def confirm_value(self): + def confirm_value(self, asset_names): new_task_name = self._selected_items[0] - origin_value = copy.deepcopy(self._origin_value) - new_origin_value = [ + self._origin_value = [ (asset_name, new_task_name) - for (asset_name, task_name) in origin_value + for asset_name in asset_names ] - - self._origin_value = new_origin_value self._origin_selection = copy.deepcopy(self._selected_items) self._has_value_changed = False @@ -1180,6 +1177,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): subset_names = set() invalid_tasks = False + asset_names = [] for instance in self._current_instances: new_variant_value = instance.get("variant") new_asset_name = instance.get("asset") @@ -1193,6 +1191,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if task_name is not None: new_task_name = task_name + asset_names.append(new_asset_name) try: new_subset_name = self._controller.get_subset_name( instance.creator_identifier, @@ -1237,7 +1236,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self.asset_value_widget.confirm_value() if task_name is not None: - self.task_value_widget.confirm_value() + self.task_value_widget.confirm_value(asset_names) self.instance_context_changed.emit() From b8ed125569bdfd7c343d08483bfd70431d38f11d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 7 Nov 2023 17:44:33 +0100 Subject: [PATCH 1034/1224] set spacing between buttons --- openpype/tools/publisher/widgets/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 9b31697749..6dbeaad821 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1127,6 +1127,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) + btns_layout.setSpacing(5) btns_layout.addWidget(submit_btn) btns_layout.addWidget(cancel_btn) From 8c8c083395b77e920254e8dd5d83ad06eb0c636d Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 8 Nov 2023 03:24:51 +0000 Subject: [PATCH 1035/1224] [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 9832c77291..8500b78966 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5" +__version__ = "3.17.6-nightly.1" From 3cd8fc6a0a8162a73d647f17a66294b0c79b2724 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Nov 2023 03:25:29 +0000 Subject: [PATCH 1036/1224] 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 bdfc2ad46f..5d4db81a77 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.6-nightly.1 - 3.17.5 - 3.17.5-nightly.3 - 3.17.5-nightly.2 @@ -134,7 +135,6 @@ body: - 3.15.1 - 3.15.1-nightly.6 - 3.15.1-nightly.5 - - 3.15.1-nightly.4 validations: required: true - type: dropdown From cfc54439095140f2b6e2d84adc9ebfa09e96a4d3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 16:35:28 +0800 Subject: [PATCH 1037/1224] clean up code tweaks for OP settings(not suitable for ayon yet) --- .../plugins/publish/validate_loaded_plugin.py | 82 ++++++++++--------- openpype/settings/ayon_settings.py | 3 + .../schemas/schema_max_publish.json | 10 ++- .../max/server/settings/publishers.py | 5 +- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index e58685cc4d..8d59bbc120 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -51,34 +51,36 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, get_plugins()) } - for families, plugin in required_plugins.items(): - # Out of for loop build the instance family lookup - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - self.log.debug(f"{instance_families}") - # In the for loop check whether any family matches + # Build instance families lookup + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + self.log.debug(f"Checking plug-in validation for instance families: {instance_families}") + for families in required_plugins.keys(): + # Check for matching families match_families = {fam.strip() for fam in families.split(",") if fam.strip()} - self.log.debug(f"match_families: {match_families}") + self.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( - instance_families) or families == "_" + instance_families) + if not has_match: continue - if not plugin: - return + plugins = [plugin for plugin in required_plugins[families]["plugins"]] + for plugin in plugins: + if not plugin: + return + plugin_name = plugin.format(**os.environ).lower() + plugin_index = available_plugins.get(plugin_name) - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) + if plugin_index is None: + invalid.append( + f"Plugin {plugin} does not exist in 3dsMax Plugin List." + ) + continue - if plugin_index is None: - invalid.append( - f"Plugin {plugin} does not exist in 3dsMax Plugin List." - ) - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append(f"Plugin {plugin} not loaded.") + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + invalid.append(f"Plugin {plugin} not loaded.") return invalid @@ -108,25 +110,29 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, ["ValidateLoadedPlugin"] ["family_plugins_mapping"] ) - for families, plugin in required_plugins.items(): - families_list = families.split(",") - excluded_families = [family for family in families_list - if instance.data["family"] != family - and family != "_"] - if excluded_families: - cls.log.debug("The {} instance is not part of {}.".format( - instance.data["family"], excluded_families - )) - continue - if not plugin: + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug(f"Checking plug-in validation for instance families: {instance_families}") + for families in required_plugins.keys(): + match_families = {fam.strip() for fam in + families.split(",") if fam.strip()} + cls.log.debug(f"Plug-in family requirements: {match_families}") + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: continue - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) + plugins = [plugin for plugin in required_plugins[families]["plugins"]] + for plugin in plugins: + if not plugin: + return + plugin_name = plugin.format(**os.environ).lower() + plugin_index = available_plugins.get(plugin_name) - if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {plugin}") - continue + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {plugin}") + continue - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - rt.pluginManager.loadPluginDll(plugin_index) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index fa73199269..0cefd047b1 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -653,6 +653,9 @@ def _convert_3dsmax_project_settings(ayon_settings, output): family_plugin_mapping = ( ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] ) + for item in family_plugin_mapping: + if "product_types" in item: + item["families"] = item.pop("product_types") new_family_plugin_mapping = { item["families"]: item["plugins"] for item in family_plugin_mapping diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index b48ce20f5d..c44c7525da 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -72,7 +72,15 @@ "label": "Family Plugins Mapping", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "dict", + "children": [ + { + "key": "plugins", + "label": "plugins", + "type": "list", + "object_type": "text" + } + ] } } ] diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index d23acc6dd7..d7169f8b96 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -29,8 +29,9 @@ class ValidateAttributesModel(BaseSettingsModel): class FamilyPluginsMappingModel(BaseSettingsModel): _layout = "compact" - families: str = Field(title="Families") - plugins: str = Field(title="Plugins") + product_types: str = Field(title="Product Types") + plugins: list[str] = Field( + default_factory=list,title="Plugins") class ValidateLoadedPluginModel(BaseSettingsModel): From 105cf5c116ab755ddc315956b63145d6b511e7db Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 8 Nov 2023 12:31:31 +0200 Subject: [PATCH 1038/1224] fix a typo in maya ayon settings --- server_addon/maya/server/settings/creators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 84e873589d..34a54832af 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -27,7 +27,7 @@ class CreateUnrealStaticMeshModel(BaseSettingsModel): default_factory=list, title="Default Products" ) - static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") + static_mesh_prefix: str = Field("S", title="Static Mesh Prefix") collision_prefixes: list[str] = Field( default_factory=list, title="Collision Prefixes" From 60b775a4f410aec128627a8697004be5a45527cb Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 8 Nov 2023 12:31:49 +0200 Subject: [PATCH 1039/1224] fix some typos in Houdini ayon settings --- server_addon/houdini/server/settings/create.py | 2 +- server_addon/houdini/server/settings/publish.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server_addon/houdini/server/settings/create.py b/server_addon/houdini/server/settings/create.py index 81b871e83f..e8db917849 100644 --- a/server_addon/houdini/server/settings/create.py +++ b/server_addon/houdini/server/settings/create.py @@ -26,7 +26,7 @@ class CreateStaticMeshModel(BaseSettingsModel): default_factory=list, title="Default Products" ) - static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") + static_mesh_prefix: str = Field("S", title="Static Mesh Prefix") collision_prefixes: list[str] = Field( default_factory=list, title="Collision Prefixes" diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 342bf957c1..92a676b0d0 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -33,9 +33,9 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): - CollectRopFrameRange: CollectRopFrameRangeModel = Field( - default_factory=CollectRopFrameRangeModel, - title="Collect Rop Frame Range.", + CollectAssetHandles: CollectAssetHandlesModel = Field( + default_factory=CollectAssetHandlesModel, + title="Collect Asset Handles.", section="Collectors" ) ValidateContainers: BasicValidateModel = Field( @@ -60,7 +60,7 @@ class PublishPluginsModel(BaseSettingsModel): DEFAULT_HOUDINI_PUBLISH_SETTINGS = { - "CollectRopFrameRange": { + "CollectAssetHandles": { "use_asset_handles": True }, "ValidateContainers": { From 4311f4840dd3b39115e5ddd244c025d86df1f1c8 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 8 Nov 2023 12:38:21 +0200 Subject: [PATCH 1040/1224] bump addon versions --- server_addon/houdini/server/version.py | 2 +- server_addon/maya/server/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index c49a95c357..75cf7831c4 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.8" +__version__ = "0.2.9" diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index 90ce344d3e..805897cda3 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.5" +__version__ = "0.1.6" From d15dfaaf849d51c49d9a0e14d5be8f1c6f85ea2d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 18:47:42 +0800 Subject: [PATCH 1041/1224] hound & code tweak regarding to OP setting --- .../plugins/publish/validate_loaded_plugin.py | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 8d59bbc120..d348e37abc 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Validator for Loaded Plugin.""" import os -from pyblish.api import InstancePlugin, ValidatorOrder +import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import ( @@ -13,7 +13,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - InstancePlugin): + pyblish.api.InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings @@ -22,24 +22,21 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, """ - order = ValidatorOrder + order = pyblish.api.ValidatorOrder hosts = ["max"] label = "Validate Loaded Plugins" optional = True actions = [RepairAction] + family_plugins_mapping = {} + def get_invalid(self, instance): """Plugin entry point.""" if not self.is_active(instance.data): self.log.debug("Skipping Validate Loaded Plugin...") return - required_plugins = ( - instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"] - ["family_plugins_mapping"] - ) - + required_plugins = self.family_plugins_mapping if not required_plugins: return @@ -54,11 +51,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, # Build instance families lookup instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) - self.log.debug(f"Checking plug-in validation for instance families: {instance_families}") - for families in required_plugins.keys(): + self.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + for family in required_plugins: # Check for matching families match_families = {fam.strip() for fam in - families.split(",") if fam.strip()} + family.split(",") if fam.strip()} self.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) @@ -66,7 +64,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[families]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return @@ -75,7 +74,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if plugin_index is None: invalid.append( - f"Plugin {plugin} does not exist in 3dsMax Plugin List." + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." ) continue @@ -105,17 +105,14 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = ( - instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"] - ["family_plugins_mapping"] - ) + required_plugins = cls.family_plugins_mapping instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) - cls.log.debug(f"Checking plug-in validation for instance families: {instance_families}") - for families in required_plugins.keys(): + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + for family in required_plugins.keys(): match_families = {fam.strip() for fam in - families.split(",") if fam.strip()} + family.split(",") if fam.strip()} cls.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) @@ -123,7 +120,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[families]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return From 1207ef3bbbcfe1b5db9bf0b5107d6e67eb5f6c53 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 12:00:59 +0100 Subject: [PATCH 1042/1224] autofix folder path on older instances --- openpype/pipeline/create/context.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 333ab25f54..e4dcedda2c 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -2255,11 +2255,11 @@ class CreateContext: if task_name: task_names_by_asset_name[asset_name].add(task_name) - asset_names = [ + asset_names = { asset_name for asset_name in task_names_by_asset_name.keys() if asset_name is not None - ] + } fields = {"name", "data.tasks"} if AYON_SERVER_ENABLED: fields |= {"data.parents"} @@ -2270,10 +2270,12 @@ class CreateContext: )) task_names_by_asset_name = {} + asset_docs_by_name = collections.defaultdict(list) for asset_doc in asset_docs: asset_name = get_asset_name_identifier(asset_doc) tasks = asset_doc.get("data", {}).get("tasks") or {} task_names_by_asset_name[asset_name] = set(tasks.keys()) + asset_docs_by_name[asset_doc["name"]].append(asset_doc) for instance in instances: if not instance.has_valid_asset or not instance.has_valid_task: @@ -2281,6 +2283,11 @@ class CreateContext: if AYON_SERVER_ENABLED: asset_name = instance["folderPath"] + if "/" not in asset_name: + asset_docs = asset_docs_by_name.get(asset_name) + if len(asset_docs) == 1: + asset_name = get_asset_name_identifier(asset_docs[0]) + instance["folderPath"] = asset_name else: asset_name = instance["asset"] From d3804564f26b3ba1a3d304195ec42b408c2b3dff Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 12:01:20 +0100 Subject: [PATCH 1043/1224] do not yield same asset multiple times --- openpype/client/server/entities.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 9e86dfdd63..becf4abda3 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -232,10 +232,12 @@ def get_assets( else: new_asset_names.add(name) + yielded_ids = set() if folder_paths: for folder in _folders_query( project_name, con, fields, folder_paths=folder_paths, **kwargs ): + yielded_ids.add(folder["id"]) yield convert_v4_folder_to_v3(folder, project_name) if not new_asset_names: @@ -244,7 +246,9 @@ def get_assets( for folder in _folders_query( project_name, con, fields, folder_names=new_asset_names, **kwargs ): - yield convert_v4_folder_to_v3(folder, project_name) + if folder["id"] not in yielded_ids: + yielded_ids.add(folder["id"]) + yield convert_v4_folder_to_v3(folder, project_name) def get_archived_assets( From 2f23b83481de1d7cad5a5139ff0955091e980da0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 8 Nov 2023 13:38:45 +0100 Subject: [PATCH 1044/1224] traypublisher: failed validator in editorial not necessary --- .../traypublisher/plugins/publish/validate_frame_ranges.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py index 09de2d8db2..7a5a3c7fc1 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py @@ -30,12 +30,17 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, if not self.is_active(instance.data): return + # Skip the instance if does not have asset entity in database + asset_doc = instance.data.get("assetEntity") + if not asset_doc: + self.log.warning("No asset data found, skipping.") + return + if (self.skip_timelines_check and any(re.search(pattern, instance.data["task"]) for pattern in self.skip_timelines_check)): self.log.info("Skipping for {} task".format(instance.data["task"])) - asset_doc = instance.data["assetEntity"] asset_data = asset_doc["data"] frame_start = asset_data["frameStart"] frame_end = asset_data["frameEnd"] From 8f1648f87a917538b8ab139f0c1af5286aa19988 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:04:56 +0100 Subject: [PATCH 1045/1224] duplicated session 3 > session 4 --- openpype/pipeline/schema/session-4.0.json | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 openpype/pipeline/schema/session-4.0.json diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json new file mode 100644 index 0000000000..9f785939e4 --- /dev/null +++ b/openpype/pipeline/schema/session-4.0.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "title": "openpype:session-3.0", + "description": "The Avalon environment", + + "type": "object", + + "additionalProperties": true, + + "required": [ + "AVALON_PROJECT", + "AVALON_ASSET" + ], + + "properties": { + "AVALON_PROJECTS": { + "description": "Absolute path to root of project directories", + "type": "string", + "example": "/nas/projects" + }, + "AVALON_PROJECT": { + "description": "Name of project", + "type": "string", + "pattern": "^\\w*$", + "example": "Hulk" + }, + "AVALON_ASSET": { + "description": "Name of asset", + "type": "string", + "pattern": "^\\w*$", + "example": "Bruce" + }, + "AVALON_TASK": { + "description": "Name of task", + "type": "string", + "pattern": "^\\w*$", + "example": "modeling" + }, + "AVALON_APP": { + "description": "Name of host", + "type": "string", + "pattern": "^\\w*$", + "example": "maya2016" + }, + "AVALON_DB": { + "description": "Name of database", + "type": "string", + "pattern": "^\\w*$", + "example": "avalon", + "default": "avalon" + }, + "AVALON_LABEL": { + "description": "Nice name of Avalon, used in e.g. graphical user interfaces", + "type": "string", + "example": "Mindbender", + "default": "Avalon" + }, + "AVALON_TIMEOUT": { + "description": "Wherever there is a need for a timeout, this is the default value.", + "type": "string", + "pattern": "^[0-9]*$", + "default": "1000", + "example": "1000" + }, + "AVALON_INSTANCE_ID": { + "description": "Unique identifier for instances in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.instance", + "example": "avalon.instance" + }, + "AVALON_CONTAINER_ID": { + "description": "Unique identifier for a loaded representation in a working file", + "type": "string", + "pattern": "^[\\w.]*$", + "default": "avalon.container", + "example": "avalon.container" + } + } +} From e85504b80c5665d6ddf73fa3b98855db7504b18c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:08:03 +0100 Subject: [PATCH 1046/1224] use new schema in 'legacy_io' --- openpype/pipeline/legacy_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index 60fa035c22..864102dff9 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -30,7 +30,7 @@ def install(): session = session_data_from_environment(context_keys=True) - session["schema"] = "openpype:session-3.0" + session["schema"] = "openpype:session-4.0" try: schema.validate(session) except schema.ValidationError as e: From 2bc41f53bfa696ff6fe04bd1e840fd85f5fa48ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:06:17 +0100 Subject: [PATCH 1047/1224] chnaged title of schema --- openpype/pipeline/schema/session-4.0.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json index 9f785939e4..dc4791994e 100644 --- a/openpype/pipeline/schema/session-4.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "openpype:session-3.0", + "title": "openpype:session-4.0", "description": "The Avalon environment", "type": "object", From d3fc80f9055b4c6b5d2e4d95ef6a489db465169c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:06:35 +0100 Subject: [PATCH 1048/1224] allow forward slash in AVALON_ASSET --- openpype/pipeline/schema/session-4.0.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json index dc4791994e..9610d8ec64 100644 --- a/openpype/pipeline/schema/session-4.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -28,7 +28,7 @@ "AVALON_ASSET": { "description": "Name of asset", "type": "string", - "pattern": "^\\w*$", + "pattern": "^[\\/\\w]*$", "example": "Bruce" }, "AVALON_TASK": { From 3cade9a288db81c4eb6b8684641eafb947b5a183 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:06:50 +0100 Subject: [PATCH 1049/1224] removed unused keys --- openpype/pipeline/schema/session-4.0.json | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json index 9610d8ec64..54a6323e8f 100644 --- a/openpype/pipeline/schema/session-4.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -14,11 +14,6 @@ ], "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, "AVALON_PROJECT": { "description": "Name of project", "type": "string", @@ -62,20 +57,6 @@ "pattern": "^[0-9]*$", "default": "1000", "example": "1000" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" } } } From e5d3a1aeb0d9d9320eb1b201f73aab3fa383cc65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:07:06 +0100 Subject: [PATCH 1050/1224] 'AVALON_ASSET' is not required --- openpype/pipeline/schema/session-4.0.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json index 54a6323e8f..088156af85 100644 --- a/openpype/pipeline/schema/session-4.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -9,8 +9,7 @@ "additionalProperties": true, "required": [ - "AVALON_PROJECT", - "AVALON_ASSET" + "AVALON_PROJECT" ], "properties": { From 389b568f6b8f356a9be4ab89496b37fa26706337 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 14:10:06 +0100 Subject: [PATCH 1051/1224] removed AVALON_PROJECTS --- openpype/pipeline/context_tools.py | 6 +----- openpype/pipeline/mongodb.py | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 38c80c87bb..fe46bd1558 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -88,11 +88,7 @@ def registered_root(): root = _registered_root["_"] if root: return root - - root = legacy_io.Session.get("AVALON_PROJECTS") - if root: - return os.path.normpath(root) - return "" + return {} def install_host(host): diff --git a/openpype/pipeline/mongodb.py b/openpype/pipeline/mongodb.py index 41a44c7373..c948983c3d 100644 --- a/openpype/pipeline/mongodb.py +++ b/openpype/pipeline/mongodb.py @@ -62,8 +62,6 @@ def auto_reconnect(func): SESSION_CONTEXT_KEYS = ( - # Root directory of projects on disk - "AVALON_PROJECTS", # Name of current Project "AVALON_PROJECT", # Name of current Asset From eda0afc26052f1ac1d5a6088aed71db94e8c3010 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 8 Nov 2023 14:27:47 +0100 Subject: [PATCH 1052/1224] traypublisher: adding exceptions for editorial instances --- .../traypublisher/plugins/create/create_editorial.py | 2 ++ .../plugins/publish/collect_sequence_frame_data.py | 6 ++++++ .../plugins/publish/validate_frame_ranges.py | 8 ++++---- openpype/plugins/publish/collect_resources_path.py | 6 ++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 8640500b18..a2746f115f 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -701,6 +701,8 @@ or updating already created. Publishing will create OTIO file. # parent time properties "trackStartFrame": track_start_frame, "timelineOffset": timeline_offset, + "isEditorial": True, + # creator_attributes "creator_attributes": creator_attributes } diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py index db70d4fe0a..92cedf6b5b 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py @@ -27,6 +27,12 @@ class CollectSequenceFrameData( if not self.is_active(instance.data): return + # editorial would fail since they might not be in database yet + is_editorial = instance.data.get("isEditorial") + if is_editorial: + self.log.debug("Instance is Editorial. Skipping.") + return + frame_data = self.get_frame_data_from_repre_sequence(instance) if not frame_data: diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py index 7a5a3c7fc1..4977a13374 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py @@ -30,10 +30,10 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, if not self.is_active(instance.data): return - # Skip the instance if does not have asset entity in database - asset_doc = instance.data.get("assetEntity") - if not asset_doc: - self.log.warning("No asset data found, skipping.") + # editorial would fail since they might not be in database yet + is_editorial = instance.data.get("isEditorial") + if is_editorial: + self.log.debug("Instance is Editorial. Skipping.") return if (self.skip_timelines_check and diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index cfb4d63c1b..14c13310df 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -68,6 +68,12 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): ] def process(self, instance): + # editorial would fail since they might not be in database yet + is_editorial = instance.data.get("isEditorial") + if is_editorial: + self.log.debug("Instance is Editorial. Skipping.") + return + anatomy = instance.context.data["anatomy"] template_data = copy.deepcopy(instance.data["anatomyData"]) From ea69e9943ec21cbcb0c5094ed6f063dd393cccb8 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 8 Nov 2023 15:56:37 +0200 Subject: [PATCH 1053/1224] update houdini license validator --- .../validate_houdini_license_category.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py index f1c52f22c1..e0e06e37c8 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py +++ b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py @@ -3,30 +3,29 @@ import pyblish.api from openpype.pipeline import PublishValidationError -class ValidateHoudiniCommercialLicense(pyblish.api.InstancePlugin): - """Validate the Houdini instance runs a Commercial license. +class ValidateHoudiniNotApprenticeLicense(pyblish.api.InstancePlugin): + """Validate the Houdini instance runs a non Apprentice license. - When extracting USD files from a non-commercial Houdini license, even with - Houdini Indie license, the resulting files will get "scrambled" with - a license protection and get a special .usdnc or .usdlc suffix. + When extracting USD files from an apprentice Houdini license, + the resulting files will get "scrambled" with a license protection + and get a special .usdnc or .usdlc suffix. This currently breaks the Subset/representation pipeline so we disallow - any publish with those licenses. Only the commercial license is valid. + any publish with apprentice license. """ order = pyblish.api.ValidatorOrder families = ["usd"] hosts = ["houdini"] - label = "Houdini Commercial License" + label = "Houdini Apprentice License" def process(self, instance): import hou - license = hou.licenseCategory() - if license != hou.licenseCategoryType.Commercial: + if hou.isApprentice(): raise PublishValidationError( - ("USD Publishing requires a full Commercial " - "license. You are on: {}").format(license), + ("USD Publishing requires a non apprentice " + "license."), title=self.label) From d410899714cffcf75ab4be154b78af6acd99a601 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 22:05:33 +0800 Subject: [PATCH 1054/1224] add ayon settings support for validate loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 11 ++++---- openpype/settings/ayon_settings.py | 19 ++++++-------- .../max/server/settings/publishers.py | 25 +++++++++++++------ 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index d348e37abc..a681dc507f 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -63,12 +63,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] + plugins = [plugin for plugin in required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return + # make sure the validation applied for + # plugins with different Max version plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) @@ -110,7 +110,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families.update(instance.data.get("families", [])) cls.log.debug("Checking plug-in validation " f"for instance families: {instance_families}") - for family in required_plugins.keys(): + for family in required_plugins: match_families = {fam.strip() for fam in family.split(",") if fam.strip()} cls.log.debug(f"Plug-in family requirements: {match_families}") @@ -120,8 +120,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] + plugins = [plugin for plugin in family["plugins"]] for plugin in plugins: if not plugin: return diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 0cefd047b1..8fd7f990c4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -650,19 +650,14 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_publish["ValidateAttributes"]["attributes"] = attributes if "ValidateLoadedPlugin" in ayon_publish: - family_plugin_mapping = ( - ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] - ) - for item in family_plugin_mapping: - if "product_types" in item: - item["families"] = item.pop("product_types") - new_family_plugin_mapping = { - item["families"]: item["plugins"] - for item in family_plugin_mapping - } - ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( - new_family_plugin_mapping + new_plugin_mapping = {} + loaded_plugin = ( + ayon_publish["ValidateLoadedPlugin"] ) + for item in loaded_plugin["family_plugins_mapping"]: + name = item.pop("name") + new_plugin_mapping[name] = item + loaded_plugin["family_plugins_mapping"] = new_plugin_mapping output["max"] = ayon_max diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index d7169f8b96..cf482d59d8 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,7 +1,7 @@ import json from pydantic import Field, validator -from ayon_server.settings import BaseSettingsModel +from ayon_server.settings import BaseSettingsModel, ensure_unique_names from ayon_server.exceptions import BadRequestException @@ -27,20 +27,31 @@ class ValidateAttributesModel(BaseSettingsModel): return value -class FamilyPluginsMappingModel(BaseSettingsModel): +class FamilyMappingItemModel(BaseSettingsModel): _layout = "compact" - product_types: str = Field(title="Product Types") + name: str = Field("", title="Product type") plugins: list[str] = Field( - default_factory=list,title="Plugins") + default_factory=list, + title="Plugins" + ) class ValidateLoadedPluginModel(BaseSettingsModel): - enabled: bool = Field(title="ValidateLoadedPlugin") + enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") - family_plugins_mapping: list[FamilyPluginsMappingModel] = Field( - default_factory=list, title="Family Plugins Mapping" + family_plugins_mapping: list[FamilyMappingItemModel] = ( + Field( + default_factory=list, + title="Family Plugins Mapping" + ) ) + # This is to validate unique names (like in dict) + @validator("family_plugins_mapping") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") From 57131a8b40bb6633aaeda75528a58bb3967298b3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 22:07:36 +0800 Subject: [PATCH 1055/1224] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- server_addon/max/server/settings/publishers.py | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index a681dc507f..06486e94a6 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -63,7 +63,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[family]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index cf482d59d8..a752d8cb74 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -39,11 +39,9 @@ class FamilyMappingItemModel(BaseSettingsModel): class ValidateLoadedPluginModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") - family_plugins_mapping: list[FamilyMappingItemModel] = ( - Field( + family_plugins_mapping: list[FamilyMappingItemModel] = Field( default_factory=list, title="Family Plugins Mapping" - ) ) # This is to validate unique names (like in dict) From af9718f753b836542aa55a837123caa655beb369 Mon Sep 17 00:00:00 2001 From: Mustafa Taher Date: Wed, 8 Nov 2023 16:25:17 +0200 Subject: [PATCH 1056/1224] BigRoy's comment - update doc string Co-authored-by: Roy Nieterau --- .../plugins/publish/validate_houdini_license_category.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py index e0e06e37c8..fd6ad9e3be 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py +++ b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py @@ -8,7 +8,7 @@ class ValidateHoudiniNotApprenticeLicense(pyblish.api.InstancePlugin): When extracting USD files from an apprentice Houdini license, the resulting files will get "scrambled" with a license protection - and get a special .usdnc or .usdlc suffix. + and get a special .usdnc suffix. This currently breaks the Subset/representation pipeline so we disallow any publish with apprentice license. From 81d054799c5761180baca44dba95a4f00a0f32de Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:26:21 +0100 Subject: [PATCH 1057/1224] Apply suggestions from code review Co-authored-by: Roy Nieterau --- openpype/pipeline/schema/session-4.0.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/schema/session-4.0.json b/openpype/pipeline/schema/session-4.0.json index 088156af85..0dab48aa46 100644 --- a/openpype/pipeline/schema/session-4.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -35,7 +35,7 @@ "description": "Name of host", "type": "string", "pattern": "^\\w*$", - "example": "maya2016" + "example": "maya" }, "AVALON_DB": { "description": "Name of database", @@ -47,7 +47,7 @@ "AVALON_LABEL": { "description": "Nice name of Avalon, used in e.g. graphical user interfaces", "type": "string", - "example": "Mindbender", + "example": "MyLabel", "default": "Avalon" }, "AVALON_TIMEOUT": { From 1676f37d3aa98633ff9ac8531285b850d7c5ba28 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 16:32:33 +0100 Subject: [PATCH 1058/1224] change default value of registered root --- openpype/pipeline/context_tools.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index fe46bd1558..33eb335ab9 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -43,7 +43,7 @@ from . import ( _is_installed = False _process_id = None -_registered_root = {"_": ""} +_registered_root = {"_": {}} _registered_host = {"_": None} # Keep modules manager (and it's modules) in memory # - that gives option to register modules' callbacks @@ -85,10 +85,7 @@ def register_root(path): def registered_root(): """Return currently registered root""" - root = _registered_root["_"] - if root: - return root - return {} + return _registered_root["_"] def install_host(host): From 74d0f944afd6ce2e719204c0505981f5fd77d952 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 8 Nov 2023 15:44:32 +0000 Subject: [PATCH 1059/1224] Do not pack image if it is already packed --- .../blender/plugins/publish/extract_blend.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index c8eeef7fd7..645314e50e 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -28,16 +28,22 @@ class ExtractBlend(publish.Extractor): for obj in instance: data_blocks.add(obj) # Pack used images in the blend files. - if obj.type == 'MESH': - for material_slot in obj.material_slots: - mat = material_slot.material - if mat and mat.use_nodes: - tree = mat.node_tree - if tree.type == 'SHADER': - for node in tree.nodes: - if node.bl_idname == 'ShaderNodeTexImage': - if node.image: - node.image.pack() + if obj.type != 'MESH': + continue + for material_slot in obj.material_slots: + mat = material_slot.material + if not(mat and mat.use_nodes): + continue + tree = mat.node_tree + if tree.type != 'SHADER': + continue + for node in tree.nodes: + if node.bl_idname != 'ShaderNodeTexImage': + continue + # Check if image is not packed already + # and pack it if not. + if node.image and node.image.packed_file is None: + node.image.pack() bpy.data.libraries.write(filepath, data_blocks) From 06856644d160155dee3b378ad2326cd44babf5b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 8 Nov 2023 17:07:48 +0100 Subject: [PATCH 1060/1224] modify 'registered_root' function docstring --- openpype/pipeline/context_tools.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 33eb335ab9..71f41fd234 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -84,7 +84,21 @@ def register_root(path): def registered_root(): - """Return currently registered root""" + """Return registered roots from current project anatomy. + + Consider this does return roots only for current project and current + platforms, only if host was installer using 'install_host'. + + Deprecated: + Please use project 'Anatomy' to get roots. This function is still used + at current core functions of load logic, but that will change + in future and this function will be removed eventually. Using this + function at new places can cause problems in the future. + + Returns: + dict[str, str]: Root paths. + """ + return _registered_root["_"] From b3649b5c5cd1167acb6de9d63ec3c50263be2408 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 13:29:48 +0800 Subject: [PATCH 1061/1224] improve debug message & use debug instead of info for artist-facing report --- openpype/hosts/maya/api/fbx.py | 4 ++-- .../hosts/maya/plugins/publish/extract_fbx_animation.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index dbb3578f08..c8f4050bc1 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -156,7 +156,7 @@ class FBXExtractor: # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Collect the start and end including handles start = instance.data.get("frameStartHandle") or \ @@ -186,7 +186,7 @@ class FBXExtractor: template = "FBXExport{0} {1}" if key == "UpAxis" else \ "FBXExport{0} -v {1}" # noqa cmd = template.format(key, value) - self.log.info(cmd) + self.log.debug(cmd) mel.eval(cmd) # Never show the UI or generate a log diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py index c6f8029e7d..c13d349394 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -43,9 +43,13 @@ class ExtractFBXAnimation(publish.Extractor): # FBX does not include the namespace but preserves the node # names as existing in the rig workfile if not out_members: + skeleton_set = [ + i for i in instance + if i.endswith("skeletonAnim_SET") + ] self.log.debug( - "Top group of animated skeleton not found.." - "skipping extraction") + "Top group of animated skeleton not found in " + "{}.\nSkipping fbx animation extraction".format(skeleton_set)) return namespace = get_namespace(out_members[0]) From ed60c361bab707bdd06d6de8fc09e828ff57c639 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 13:30:31 +0800 Subject: [PATCH 1062/1224] hound --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 4 ++-- 1 file changed, 2 insertions(+), 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 c13d349394..e88b8b1e16 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -44,8 +44,8 @@ class ExtractFBXAnimation(publish.Extractor): # names as existing in the rig workfile if not out_members: skeleton_set = [ - i for i in instance - if i.endswith("skeletonAnim_SET") + i for i in instance + if i.endswith("skeletonAnim_SET") ] self.log.debug( "Top group of animated skeleton not found in " From 0403af298e795cdc7a206b88485f40bd6e07072b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 17:21:37 +0800 Subject: [PATCH 1063/1224] tweaks the codes to use list instead of dict for OP settings --- .../plugins/publish/validate_loaded_plugin.py | 141 +++++++++--------- .../defaults/project_settings/max.json | 2 +- .../schemas/schema_max_publish.json | 10 +- 3 files changed, 77 insertions(+), 76 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 06486e94a6..7450c8f971 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -17,9 +17,6 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings - If families = ["*"], all the required plugins would be validated - If families - """ order = pyblish.api.ValidatorOrder @@ -30,66 +27,77 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, family_plugins_mapping = {} - def get_invalid(self, instance): + @classmethod + def get_invalid(cls, instance): """Plugin entry point.""" - if not self.is_active(instance.data): - self.log.debug("Skipping Validate Loaded Plugin...") - return - - required_plugins = self.family_plugins_mapping - if not required_plugins: + family_plugins_mapping = cls.family_plugins_mapping + if not family_plugins_mapping: return invalid = [] + # Find all plug-in requirements for current instance + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + all_required_plugins = set() + + for mapping in family_plugins_mapping: + # Check for matching families + if not mapping: + return + + match_families = {fam for fam in mapping["families"] if fam.strip()} + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: + continue + + cls.log.debug(f"Found plug-in family requirements: {match_families}") + required_plugins = [ + # match lowercase and format with os.environ to allow + # plugin names defined by max version, e.g. {3DSMAX_VERSION} + plugin.format(**os.environ).lower() + for plugin in mapping["plugins"] + # ignore empty fields in settings + if plugin.strip() + ] + + all_required_plugins.update(required_plugins) + + if not all_required_plugins: + # Instance has no plug-in requirements + return # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - - # Build instance families lookup - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - self.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") - for family in required_plugins: - # Check for matching families - match_families = {fam.strip() for fam in - family.split(",") if fam.strip()} - self.log.debug(f"Plug-in family requirements: {match_families}") - has_match = "*" in match_families or match_families.intersection( - instance_families) - - if not has_match: + # validate the required plug-ins + for plugin in sorted(all_required_plugins): + plugin_index = available_plugins.get(plugin) + if plugin_index is None: + debug_msg = ( + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." + ) + invalid.append((plugin, debug_msg)) continue - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] - for plugin in plugins: - if not plugin: - return - # make sure the validation applied for - # plugins with different Max version - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) - - if plugin_index is None: - invalid.append( - f"Plugin {plugin} does not exist" - " in 3dsMax Plugin List." - ) - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append(f"Plugin {plugin} not loaded.") - + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + debug_msg = f"Plugin {plugin} not loaded." + invalid.append((plugin, debug_msg)) return invalid def process(self, instance): - invalid_plugins = self.get_invalid(instance) - if invalid_plugins: + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return + invalid = self.get_invalid(instance) + if invalid: bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid) for invalid in invalid_plugins + "- {}".format(message) for _, message in invalid ) report = ( "Required plugins are not loaded.\n\n" @@ -101,36 +109,23 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, @classmethod def repair(cls, instance): + # get all DLL loaded plugins in Max and their plugin index + invalid = cls.get_invalid(instance) + if not invalid: + return + # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = cls.family_plugins_mapping - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - cls.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") - for family in required_plugins: - match_families = {fam.strip() for fam in - family.split(",") if fam.strip()} - cls.log.debug(f"Plug-in family requirements: {match_families}") - has_match = "*" in match_families or match_families.intersection( - instance_families) - if not has_match: + for invalid_plugin, _ in invalid: + plugin_index = available_plugins.get(invalid_plugin) + + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {invalid_plugin}") continue - plugins = [plugin for plugin in family["plugins"]] - for plugin in plugins: - if not plugin: - return - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) - - if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {plugin}") - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - rt.pluginManager.loadPluginDll(plugin_index) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 57927b48c7..92049cdbe9 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -44,7 +44,7 @@ "ValidateLoadedPlugin": { "enabled": false, "optional": true, - "family_plugins_mapping": {} + "family_plugins_mapping": [] } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index c44c7525da..c6d37ae993 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -66,7 +66,7 @@ "label": "Optional" }, { - "type": "dict-modifiable", + "type": "list", "collapsible": true, "key": "family_plugins_mapping", "label": "Family Plugins Mapping", @@ -74,9 +74,15 @@ "object_type": { "type": "dict", "children": [ + { + "key": "families", + "label": "Famiies", + "type": "list", + "object_type": "text" + }, { "key": "plugins", - "label": "plugins", + "label": "Plugins", "type": "list", "object_type": "text" } From f52af5bcc464621066d1db19a29c0e31bb6f7773 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 17:23:11 +0800 Subject: [PATCH 1064/1224] add the full stop --- openpype/hosts/maya/plugins/publish/extract_fbx_animation.py | 2 +- 1 file changed, 1 insertion(+), 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 e88b8b1e16..756158d4f0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -49,7 +49,7 @@ class ExtractFBXAnimation(publish.Extractor): ] self.log.debug( "Top group of animated skeleton not found in " - "{}.\nSkipping fbx animation extraction".format(skeleton_set)) + "{}.\nSkipping fbx animation extraction.".format(skeleton_set)) return namespace = get_namespace(out_members[0]) From 0d6e728552d0a42c6338b07dd761e981111d6b6e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 10:42:38 +0000 Subject: [PATCH 1065/1224] Added function to get collections Also added a flag in old function to get selection to include collections. --- openpype/hosts/blender/api/lib.py | 54 +++++++++++++++++-- .../plugins/create/create_blendScene.py | 30 +++-------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 9bb560c364..2f33fd25ad 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -266,9 +266,57 @@ def read(node: bpy.types.bpy_struct_meta_idprop): return data -def get_selection() -> List[bpy.types.Object]: - """Return the selected objects from the current scene.""" - return [obj for obj in bpy.context.scene.objects if obj.select_get()] +def get_selected_collections(): + """ + Returns a list of the currently selected collections in the outliner. + + Raises: + RuntimeError: If the outliner cannot be found in the main Blender + window. + + Returns: + list: A list of `bpy.types.Collection` objects that are currently + selected in the outliner. + """ + try: + area = next( + area for area in bpy.context.window.screen.areas + if area.type == 'OUTLINER') + region = next( + region for region in area.regions + if region.type == 'WINDOW') + except StopIteration as e: + raise RuntimeError("Could not find outliner. An outliner space " + "must be in the main Blender window.") from e + + with bpy.context.temp_override( + window=bpy.context.window, + area=area, + region=region, + screen=bpy.context.window.screen + ): + ids = bpy.context.selected_ids + + return [id for id in ids if isinstance(id, bpy.types.Collection)] + + +def get_selection(include_collections: bool = False) -> List[bpy.types.Object]: + """ + Returns a list of selected objects in the current Blender scene. + + Args: + include_collections (bool, optional): Whether to include selected + collections in the result. Defaults to False. + + Returns: + List[bpy.types.Object]: A list of selected objects. + """ + selection = [obj for obj in bpy.context.scene.objects if obj.select_get()] + + if include_collections: + selection.extend(get_selected_collections()) + + return selection @contextlib.contextmanager diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 791e741ca7..bb57a16888 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -40,29 +40,13 @@ class CreateBlendScene(plugin.Creator): self.data['task'] = get_current_task_name() lib.imprint(asset_group, self.data) - try: - area = next( - area for area in bpy.context.window.screen.areas - if area.type == 'OUTLINER') - region = next( - region for region in area.regions - if region.type == 'WINDOW') - except StopIteration as e: - raise RuntimeError("Could not find outliner. An outliner space " - "must be in the main Blender window.") from e + if (self.options or {}).get("useSelection"): + selection = lib.get_selection(include_collections=True) - with bpy.context.temp_override( - window=bpy.context.window, - area=area, - region=region, - screen=bpy.context.window.screen - ): - ids = bpy.context.selected_ids - - for id in ids: - if isinstance(id, bpy.types.Collection): - asset_group.children.link(id) - elif isinstance(id, bpy.types.Object): - asset_group.objects.link(id) + for data in selection: + if isinstance(data, bpy.types.Collection): + asset_group.children.link(data) + elif isinstance(data, bpy.types.Object): + asset_group.objects.link(data) return asset_group From f3de6175bcb1bbbd5105602023be05fac3f717dc Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 10:44:37 +0000 Subject: [PATCH 1066/1224] Hound fixes --- openpype/hosts/blender/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 2f33fd25ad..1f68dd0839 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -287,7 +287,7 @@ def get_selected_collections(): if region.type == 'WINDOW') except StopIteration as e: raise RuntimeError("Could not find outliner. An outliner space " - "must be in the main Blender window.") from e + "must be in the main Blender window.") from e with bpy.context.temp_override( window=bpy.context.window, @@ -311,7 +311,7 @@ def get_selection(include_collections: bool = False) -> List[bpy.types.Object]: Returns: List[bpy.types.Object]: A list of selected objects. """ - selection = [obj for obj in bpy.context.scene.objects if obj.select_get()] + selection = [obj for obj in bpy.context.scene.objects if obj.select_get()] if include_collections: selection.extend(get_selected_collections()) From a023183ca2f24a634b3377fde17ea394ec46a38f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 11:08:36 +0000 Subject: [PATCH 1067/1224] Fix potential problem when removing data --- .../blender/plugins/load/load_blendscene.py | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index b1b2c3ba79..2c955af9e8 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -180,7 +180,7 @@ class BlendSceneLoader(plugin.AssetLoader): avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.children.link(asset_group) - # Restore the old data, but reset memebers, as they don't exist anymore + # Restore the old data, but reset members, as they don't exist anymore # This avoids a crash, because the memory addresses of those members # are not valid anymore old_data["members"] = [] @@ -202,22 +202,20 @@ class BlendSceneLoader(plugin.AssetLoader): group_name = container["objectName"] asset_group = bpy.data.collections.get(group_name) - attrs = [ - attr for attr in dir(bpy.data) - if isinstance( - getattr(bpy.data, attr), - bpy.types.bpy_prop_collection - ) - ] + members = set(asset_group.get(AVALON_PROPERTY).get("members", [])) - members = asset_group.get(AVALON_PROPERTY).get("members", []) + if members: + for attr_name in dir(bpy.data): + attr = getattr(bpy.data, attr_name) + if not isinstance(attr, bpy.types.bpy_prop_collection): + continue - for attr in attrs: - for data in getattr(bpy.data, attr): - if data in members: - # Skip the asset group - if data == asset_group: + # ensure to make a list copy because we + # we remove members as we iterate + for data in list(attr): + if data not in members or data == asset_group: continue - getattr(bpy.data, attr).remove(data) + + attr.remove(data) bpy.data.collections.remove(asset_group) From 8a040ad0a4d88b99eca7423e6bea2ea04e183a29 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 12:23:46 +0100 Subject: [PATCH 1068/1224] pass in variant to 'get_addons_settings' --- openpype/settings/ayon_settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 5eb68e3972..67ef109d8b 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1497,7 +1497,8 @@ class _AyonSettingsCache: if cls._use_bundles(): value = ayon_api.get_addons_settings( bundle_name=cls._get_bundle_name(), - project_name=project_name + project_name=project_name, + variant=cls._get_variant() ) else: value = ayon_api.get_addons_settings(project_name) From a4dbc1958011cb0fbdeef68499797606c5fabc51 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 12:56:34 +0000 Subject: [PATCH 1069/1224] Changed validator to work with other families as well --- .../plugins/publish/validate_instance_empty.py | 17 +++++++++++++---- .../blender/server/settings/publish_plugins.py | 9 ++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py index 66d8b45e1e..5abfd6dee8 100644 --- a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py +++ b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py @@ -8,11 +8,20 @@ class ValidateInstanceEmpty(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] - families = ["blendScene"] + families = ["model", "pointcache", "rig", "camera" "layout", "blendScene"] label = "Validate Instance is not Empty" optional = False def process(self, instance): - collection = bpy.data.collections[instance.name] - if not (collection.objects or collection.children): - raise RuntimeError(f"Instance {instance.name} is empty.") + self.log.debug(instance) + self.log.debug(instance.data) + if instance.data["family"] == "blendScene": + # blendScene instances are collections + collection = bpy.data.collections[instance.name] + if not (collection.objects or collection.children): + raise RuntimeError(f"Instance {instance.name} is empty.") + else: + # All other instances are objects + asset_group = bpy.data.objects[instance.name] + if not asset_group.children: + raise RuntimeError(f"Instance {instance.name} is empty.") diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index bb68b40cbb..1c4ad0c6fd 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -67,6 +67,10 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidateFileSavedModel, title="Validate File Saved", ) + ValidateInstanceEmpty: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Instance is not Empty" + ) ValidateMeshHasUvs: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Mesh Has Uvs", @@ -84,11 +88,6 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Validate No Colons In Name" ) - ValidateInstanceEmpty: ValidatePluginModel = Field( - default_factory=ValidatePluginModel, - title="Validate Instance is not Empty", - section="BlendScene Validators" - ) ValidateRenderCameraIsSet: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Render Camera Is Set", From cab0a6a3ee661828d694297d2229721dc0a2b969 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 13:03:46 +0000 Subject: [PATCH 1070/1224] Improved formatting --- openpype/hosts/blender/plugins/publish/extract_blend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index f29dae7f69..17e574c1be 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -28,11 +28,13 @@ class ExtractBlend(publish.Extractor): for data in instance: data_blocks.add(data) # Pack used images in the blend files. - if not(isinstance(data, bpy.types.Object) and data.type == 'MESH'): + if not ( + isinstance(data, bpy.types.Object) and data.type == 'MESH' + ): continue for material_slot in data.material_slots: mat = material_slot.material - if not(mat and mat.use_nodes): + if not (mat and mat.use_nodes): continue tree = mat.node_tree if tree.type != 'SHADER': From 12c8d3d2f8d79dbadca22efe8cc691c998def4a1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:17:29 +0800 Subject: [PATCH 1071/1224] add supports for ayon settings --- openpype/settings/ayon_settings.py | 9 +++------ server_addon/max/server/settings/publishers.py | 11 ++++------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8fd7f990c4..eb7e3a2d0f 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -650,14 +650,11 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_publish["ValidateAttributes"]["attributes"] = attributes if "ValidateLoadedPlugin" in ayon_publish: - new_plugin_mapping = {} loaded_plugin = ( - ayon_publish["ValidateLoadedPlugin"] + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] ) - for item in loaded_plugin["family_plugins_mapping"]: - name = item.pop("name") - new_plugin_mapping[name] = item - loaded_plugin["family_plugins_mapping"] = new_plugin_mapping + for item in loaded_plugin: + item["families"] = item.pop("product_types") output["max"] = ayon_max diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a752d8cb74..eeb6478216 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -29,7 +29,10 @@ class ValidateAttributesModel(BaseSettingsModel): class FamilyMappingItemModel(BaseSettingsModel): _layout = "compact" - name: str = Field("", title="Product type") + product_types: list[str] = Field( + default_factory=list, + title="Product Types" + ) plugins: list[str] = Field( default_factory=list, title="Plugins" @@ -44,12 +47,6 @@ class ValidateLoadedPluginModel(BaseSettingsModel): title="Family Plugins Mapping" ) - # This is to validate unique names (like in dict) - @validator("family_plugins_mapping") - def validate_unique_outputs(cls, value): - ensure_unique_names(value) - return value - class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") From e0fba84c9293fa6fe7d9ddb5aa5e1783faaa56d7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:17:42 +0800 Subject: [PATCH 1072/1224] add supports for ayon settings --- server_addon/max/server/settings/publishers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index eeb6478216..4b6429250f 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,7 +1,7 @@ import json from pydantic import Field, validator -from ayon_server.settings import BaseSettingsModel, ensure_unique_names +from ayon_server.settings import BaseSettingsModel from ayon_server.exceptions import BadRequestException From 25be7762f18518ffacc3f0ac511f647025b6fbb0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:19:14 +0800 Subject: [PATCH 1073/1224] hound --- .../max/plugins/publish/validate_loaded_plugin.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 7450c8f971..ea2fee353d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -39,7 +39,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) cls.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") + f"for instance families: {instance_families}") all_required_plugins = set() for mapping in family_plugins_mapping: @@ -47,14 +47,16 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not mapping: return - match_families = {fam for fam in mapping["families"] if fam.strip()} + match_families = {fam for fam in mapping["families"] + if fam.strip()} has_match = "*" in match_families or match_families.intersection( instance_families) if not has_match: continue - cls.log.debug(f"Found plug-in family requirements: {match_families}") + cls.log.debug( + f"Found plug-in family requirements: {match_families}") required_plugins = [ # match lowercase and format with os.environ to allow # plugin names defined by max version, e.g. {3DSMAX_VERSION} @@ -124,7 +126,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, plugin_index = available_plugins.get(invalid_plugin) if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {invalid_plugin}") + cls.log.warning( + f"Can't enable missing plugin: {invalid_plugin}") continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): From 1e0f44923909de38a76b58e59588d3c417240a7f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 14:32:57 +0100 Subject: [PATCH 1074/1224] implemented 'get_ayon_server_api_connection' to help create connection --- openpype/client/__init__.py | 3 +++ openpype/client/server/utils.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/openpype/client/__init__.py b/openpype/client/__init__.py index 7831afd8ad..fe6dc97877 100644 --- a/openpype/client/__init__.py +++ b/openpype/client/__init__.py @@ -1,6 +1,7 @@ from .mongo import ( OpenPypeMongoConnection, ) +from .server.utils import get_ayon_server_api_connection from .entities import ( get_projects, @@ -59,6 +60,8 @@ from .operations import ( __all__ = ( "OpenPypeMongoConnection", + "get_ayon_server_api_connection", + "get_projects", "get_project", "get_whole_project", diff --git a/openpype/client/server/utils.py b/openpype/client/server/utils.py index ed128cfad9..a9dcf539bd 100644 --- a/openpype/client/server/utils.py +++ b/openpype/client/server/utils.py @@ -1,8 +1,33 @@ +import os import uuid +import ayon_api + from openpype.client.operations_base import REMOVED_VALUE +class _GlobalCache: + initialized = False + + +def get_ayon_server_api_connection(): + if _GlobalCache.initialized: + con = ayon_api.get_server_api_connection() + else: + from openpype.lib.local_settings import get_local_site_id + + _GlobalCache.initialized = True + site_id = get_local_site_id() + version = os.getenv("AYON_VERSION") + if ayon_api.is_connection_created(): + con = ayon_api.get_server_api_connection() + con.set_site_id(site_id) + con.set_client_version(version) + else: + con = ayon_api.create_connection(site_id, version) + return con + + def create_entity_id(): return uuid.uuid1().hex From 8e0513fe548b15aed1edcda6f83a38c919758241 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 14:33:19 +0100 Subject: [PATCH 1075/1224] use 'get_ayon_server_api_connection' in server functions --- openpype/client/server/entities.py | 27 +++++++++++++------------- openpype/client/server/entity_links.py | 14 ++++++------- openpype/client/server/operations.py | 11 +++++------ 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 16223d3d91..b41727a797 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -1,9 +1,8 @@ import collections -from ayon_api import get_server_api_connection - from openpype.client.mongo.operations import CURRENT_THUMBNAIL_SCHEMA +from .utils import get_ayon_server_api_connection from .openpype_comp import get_folders_with_tasks from .conversion_utils import ( project_fields_v3_to_v4, @@ -37,7 +36,7 @@ def get_projects(active=True, inactive=False, library=None, fields=None): elif inactive: active = False - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) for project in con.get_projects(active, library, fields=fields): yield convert_v4_project_to_v3(project) @@ -45,7 +44,7 @@ def get_projects(active=True, inactive=False, library=None, fields=None): def get_project(project_name, active=True, inactive=False, fields=None): # Skip if both are disabled - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) return convert_v4_project_to_v3( con.get_project(project_name, fields=fields) @@ -66,7 +65,7 @@ def _get_subsets( fields=None ): # Convert fields and add minimum required fields - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = subset_fields_v3_to_v4(fields, con) if fields is not None: for key in ( @@ -102,7 +101,7 @@ def _get_versions( active=None, fields=None ): - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = version_fields_v3_to_v4(fields, con) @@ -198,7 +197,7 @@ def get_assets( if archived: active = None - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = folder_fields_v3_to_v4(fields, con) kwargs = dict( folder_ids=asset_ids, @@ -236,7 +235,7 @@ def get_archived_assets( def get_asset_ids_with_subsets(project_name, asset_ids=None): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.get_folder_ids_with_products(project_name, asset_ids) @@ -282,7 +281,7 @@ def get_subsets( def get_subset_families(project_name, subset_ids=None): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.get_product_type_names(project_name, subset_ids) @@ -430,7 +429,7 @@ def get_output_link_versions(project_name, version_id, fields=None): if not version_id: return [] - con = get_server_api_connection() + con = get_ayon_server_api_connection() version_links = con.get_version_links( project_name, version_id, link_direction="out") @@ -446,7 +445,7 @@ def get_output_link_versions(project_name, version_id, fields=None): def version_is_latest(project_name, version_id): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.version_is_latest(project_name, version_id) @@ -501,7 +500,7 @@ def get_representations( else: active = None - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = representation_fields_v3_to_v4(fields, con) if fields and active is not None: fields.add("active") @@ -535,7 +534,7 @@ def get_representations_parents(project_name, representations): repre["_id"] for repre in representations } - con = get_server_api_connection() + con = get_ayon_server_api_connection() parents_by_repre_id = con.get_representations_parents(project_name, repre_ids) folder_ids = set() @@ -677,7 +676,7 @@ def get_workfile_info( if not asset_id or not task_name or not filename: return None - con = get_server_api_connection() + con = get_ayon_server_api_connection() task = con.get_task_by_name( project_name, asset_id, task_name, fields=["id", "name", "folderId"] ) diff --git a/openpype/client/server/entity_links.py b/openpype/client/server/entity_links.py index d8395aabe7..368dcdcb9d 100644 --- a/openpype/client/server/entity_links.py +++ b/openpype/client/server/entity_links.py @@ -1,6 +1,4 @@ -import ayon_api -from ayon_api import get_folder_links, get_versions_links - +from .utils import get_ayon_server_api_connection from .entities import get_assets, get_representation_by_id @@ -28,7 +26,8 @@ def get_linked_asset_ids(project_name, asset_doc=None, asset_id=None): if not asset_id: asset_id = asset_doc["_id"] - links = get_folder_links(project_name, asset_id, link_direction="in") + con = get_ayon_server_api_connection() + links = con.get_folder_links(project_name, asset_id, link_direction="in") return [ link["entityId"] for link in links @@ -115,6 +114,7 @@ def get_linked_representation_id( if link_type: link_types = [link_type] + con = get_ayon_server_api_connection() # Store already found version ids to avoid recursion, and also to store # output -> Don't forget to remove 'version_id' at the end!!! linked_version_ids = {version_id} @@ -124,7 +124,7 @@ def get_linked_representation_id( if not versions_to_check: break - links = get_versions_links( + links = con.get_versions_links( project_name, versions_to_check, link_types=link_types, @@ -145,8 +145,8 @@ def get_linked_representation_id( linked_version_ids.remove(version_id) if not linked_version_ids: return [] - - representations = ayon_api.get_representations( + con = get_ayon_server_api_connection() + representations = con.get_representations( project_name, version_ids=linked_version_ids, fields=["id"]) diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py index 5b38405c34..eddc1eaf60 100644 --- a/openpype/client/server/operations.py +++ b/openpype/client/server/operations.py @@ -5,7 +5,6 @@ import uuid import datetime from bson.objectid import ObjectId -from ayon_api import get_server_api_connection from openpype.client.operations_base import ( REMOVED_VALUE, @@ -41,7 +40,7 @@ from .conversion_utils import ( convert_update_representation_to_v4, convert_update_workfile_info_to_v4, ) -from .utils import create_entity_id +from .utils import create_entity_id, get_ayon_server_api_connection def _create_or_convert_to_id(entity_id=None): @@ -680,7 +679,7 @@ class OperationsSession(BaseOperationsSession): def __init__(self, con=None, *args, **kwargs): super(OperationsSession, self).__init__(*args, **kwargs) if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() self._con = con self._project_cache = {} self._nested_operations = collections.defaultdict(list) @@ -858,7 +857,7 @@ def create_project( """ if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.create_project( project_name, @@ -870,12 +869,12 @@ def create_project( def delete_project(project_name, con=None): if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.delete_project(project_name) def create_thumbnail(project_name, src_filepath, thumbnail_id=None, con=None): if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.create_thumbnail(project_name, src_filepath, thumbnail_id) From 14ddd5cb51e8785f27b5be5406b358792bbe6328 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 14:36:42 +0100 Subject: [PATCH 1076/1224] use 'get_ayon_server_api_connection' in core functions --- openpype/lib/local_settings.py | 6 +++--- openpype/modules/base.py | 10 +++++++--- openpype/pipeline/anatomy.py | 6 +++--- openpype/pipeline/context_tools.py | 6 ++++++ openpype/pipeline/thumbnail.py | 6 ++---- openpype/settings/ayon_settings.py | 28 +++++++++++++++++++--------- 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 9b780fd88a..ea42d2f0b5 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -36,6 +36,7 @@ from openpype.settings import ( ) from openpype.client.mongo import validate_mongo_connection +from openpype.client import get_ayon_server_api_connection _PLACEHOLDER = object() @@ -613,9 +614,8 @@ def get_openpype_username(): """ if AYON_SERVER_ENABLED: - import ayon_api - - return ayon_api.get_user()["name"] + con = get_ayon_server_api_connection() + return con.get_user()["name"] username = os.environ.get("OPENPYPE_USERNAME") if not username: diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 457e29905d..4636906cec 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -16,9 +16,9 @@ from abc import ABCMeta, abstractmethod import six import appdirs -import ayon_api from openpype import AYON_SERVER_ENABLED +from openpype.client import get_ayon_server_api_connection from openpype.settings import ( get_system_settings, SYSTEM_SETTINGS_KEY, @@ -319,8 +319,11 @@ def load_modules(force=False): def _get_ayon_bundle_data(): + con = get_ayon_server_api_connection() + bundles = con.get_bundles()["bundles"] + bundle_name = os.getenv("AYON_BUNDLE_NAME") - bundles = ayon_api.get_bundles()["bundles"] + return next( ( bundle @@ -345,7 +348,8 @@ def _get_ayon_addons_information(bundle_info): output = [] bundle_addons = bundle_info["addons"] - addons = ayon_api.get_addons_info()["addons"] + con = get_ayon_server_api_connection() + addons = con.get_addons_info()["addons"] for addon in addons: name = addon["name"] versions = addon.get("versions") diff --git a/openpype/pipeline/anatomy.py b/openpype/pipeline/anatomy.py index 029b5cc1ff..0e5ab1d42e 100644 --- a/openpype/pipeline/anatomy.py +++ b/openpype/pipeline/anatomy.py @@ -5,7 +5,6 @@ import platform import collections import numbers -import ayon_api import six import time @@ -16,7 +15,7 @@ from openpype.settings.lib import ( from openpype.settings.constants import ( DEFAULT_PROJECT_KEY ) -from openpype.client import get_project +from openpype.client import get_project, get_ayon_server_api_connection from openpype.lib import Logger, get_local_site_id from openpype.lib.path_templates import ( TemplateUnsolved, @@ -479,7 +478,8 @@ class Anatomy(BaseAnatomy): if AYON_SERVER_ENABLED: if not project_name: return - return ayon_api.get_project_roots_for_site( + con = get_ayon_server_api_connection() + return con.get_project_roots_for_site( project_name, get_local_site_id() ) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 5afdb30f7b..034bbc0070 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -11,12 +11,14 @@ import pyblish.api from pyblish.lib import MessageHandler import openpype +from openpype import AYON_SERVER_ENABLED from openpype.host import HostBase from openpype.client import ( get_project, get_asset_by_id, get_asset_by_name, version_is_latest, + get_ayon_server_api_connection, ) from openpype.lib.events import emit_event from openpype.modules import load_modules, ModulesManager @@ -105,6 +107,10 @@ def install_host(host): _is_installed = True + # Make sure global AYON connection has set site id and version + if AYON_SERVER_ENABLED: + get_ayon_server_api_connection() + legacy_io.install() modules_manager = _get_modules_manager() diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index 63c55d0c19..14fb8b06fc 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -4,7 +4,7 @@ import logging from openpype import AYON_SERVER_ENABLED from openpype.lib import Logger -from openpype.client import get_project +from openpype.client import get_project, get_ayon_server_api_connection from . import legacy_io from .anatomy import Anatomy from .plugin_discover import ( @@ -153,8 +153,6 @@ class ServerThumbnailResolver(ThumbnailResolver): if not entity_type or not entity_id: return None - import ayon_api - project_name = self.dbcon.active_project() thumbnail_id = thumbnail_entity["_id"] @@ -169,7 +167,7 @@ class ServerThumbnailResolver(ThumbnailResolver): # 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() + con = get_ayon_server_api_connection() if hasattr(con, "get_thumbnail_by_id"): result = con.get_thumbnail_by_id(thumbnail_id) if result.is_valid: diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 67ef109d8b..745cadfc6e 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -20,7 +20,8 @@ import copy import time import six -import ayon_api + +from openpype.client import get_ayon_server_api_connection def _convert_color(color_value): @@ -1445,7 +1446,8 @@ class _AyonSettingsCache: @classmethod def _use_bundles(cls): if _AyonSettingsCache.use_bundles is None: - major, minor, _, _, _ = ayon_api.get_server_version_tuple() + con = get_ayon_server_api_connection() + major, minor, _, _, _ = con.get_server_version_tuple() use_bundles = True if (major, minor) < (0, 3): use_bundles = False @@ -1462,6 +1464,8 @@ class _AyonSettingsCache: variant = cls._get_dev_mode_settings_variant() elif is_staging_enabled(): variant = "staging" + + # Cache variant _AyonSettingsCache.variant = variant return _AyonSettingsCache.variant @@ -1477,8 +1481,9 @@ class _AyonSettingsCache: str: Name of settings variant. """ - bundles = ayon_api.get_bundles() - user = ayon_api.get_user() + con = get_ayon_server_api_connection() + bundles = con.get_bundles() + user = con.get_user() username = user["name"] for bundle in bundles["bundles"]: if ( @@ -1494,21 +1499,23 @@ class _AyonSettingsCache: def get_value_by_project(cls, project_name): cache_item = _AyonSettingsCache.cache_by_project_name[project_name] if cache_item.is_outdated: + con = get_ayon_server_api_connection() if cls._use_bundles(): - value = ayon_api.get_addons_settings( + value = con.get_addons_settings( bundle_name=cls._get_bundle_name(), project_name=project_name, variant=cls._get_variant() ) else: - value = ayon_api.get_addons_settings(project_name) + value = con.get_addons_settings(project_name) cache_item.update_value(value) return cache_item.get_value() @classmethod def _get_addon_versions_from_bundle(cls): + con = get_ayon_server_api_connection() expected_bundle = cls._get_bundle_name() - bundles = ayon_api.get_bundles()["bundles"] + bundles = con.get_bundles()["bundles"] bundle = next( ( bundle @@ -1528,8 +1535,11 @@ class _AyonSettingsCache: if cls._use_bundles(): addons = cls._get_addon_versions_from_bundle() else: - settings_data = ayon_api.get_addons_settings( - only_values=False, variant=cls._get_variant()) + con = get_ayon_server_api_connection() + settings_data = con.get_addons_settings( + only_values=False, + variant=cls._get_variant() + ) addons = settings_data["versions"] cache_item.update_value(addons) From e23a2b5e09dfb13c5ed1587a450bd5de89b7ef35 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 14:36:55 +0100 Subject: [PATCH 1077/1224] set default variant in ayon_settings --- openpype/settings/ayon_settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 745cadfc6e..f0b4528802 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1467,6 +1467,10 @@ class _AyonSettingsCache: # Cache variant _AyonSettingsCache.variant = variant + + # Set the variant to global ayon api connection + con = get_ayon_server_api_connection() + con.set_default_settings_variant(variant) return _AyonSettingsCache.variant @classmethod From 0b8dde268708594e23d1f305a7bfbd3da99a55d9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 9 Nov 2023 15:47:14 +0100 Subject: [PATCH 1078/1224] resolve: creating clips with folder path also converting dict() to {} --- openpype/hosts/resolve/api/plugin.py | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 49c3b484d2..197f288150 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -19,7 +19,7 @@ from .menu import load_stylesheet class CreatorWidget(QtWidgets.QDialog): # output items - items = dict() + items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -101,7 +101,7 @@ class CreatorWidget(QtWidgets.QDialog): self.close() def value(self, data, new_data=None): - new_data = new_data or dict() + new_data = new_data or {} for k, v in data.items(): new_data[k] = { "target": None, @@ -290,7 +290,7 @@ class Spacer(QtWidgets.QWidget): class ClipLoader: active_bin = None - data = dict() + data = {} def __init__(self, loader_obj, context, **options): """ Initialize object @@ -588,8 +588,8 @@ class PublishClip: Returns: hiero.core.TrackItem: hiero track item object with openpype tag """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + tag_data = {} types = { "shot": "shot", "folder": "folder", @@ -665,15 +665,23 @@ class PublishClip: new_name = self.tag_data.pop("newClipName") if self.rename: - self.tag_data["asset"] = new_name + self.tag_data["asset_name"] = new_name else: - self.tag_data["asset"] = self.ti_name + self.tag_data["asset_name"] = self.ti_name + # AYON unique identifier + folder_path = "/{}/{}".format( + self.tag_data["hierarchy"], + self.tag_data["asset_name"] + ) + self.tag_data["folder_path"] = folder_path + + # create new name for track item if not lib.pype_marker_workflow: # create compound clip workflow lib.create_compound_clip( self.timeline_item_data, - self.tag_data["asset"], + self.tag_data["asset_name"], self.mp_folder ) @@ -765,7 +773,7 @@ class PublishClip: # increasing steps by index of rename iteration self.count_steps *= self.rename_index - hierarchy_formatting_data = dict() + hierarchy_formatting_data = {} _data = self.timeline_item_default_data.copy() if self.ui_inputs: # adding tag metadata from ui @@ -854,8 +862,7 @@ class PublishClip: "parents": self.parents, "hierarchyData": hierarchy_formatting_data, "subset": self.subset, - "family": self.subset_family, - "families": ["clip"] + "family": self.subset_family } def _convert_to_entity(self, key): From d07898a3286b139c0d48bc14fb16b1417adcd8e5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 9 Nov 2023 15:47:50 +0100 Subject: [PATCH 1079/1224] resolve: collect instances with folder path --- .../plugins/publish/precollect_instances.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py index 8ec169ad65..58c1c85276 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py @@ -9,6 +9,7 @@ from openpype.hosts.resolve.api.lib import ( get_publish_attribute, get_otio_clip_instance_data, ) +from openpype import AYON_SERVER_ENABLED class PrecollectInstances(pyblish.api.ContextPlugin): @@ -29,7 +30,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): for timeline_item_data in selected_timeline_items: - data = dict() + data = {} timeline_item = timeline_item_data["clip"]["item"] # get pype tag data @@ -60,24 +61,25 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if k not in ("id", "applieswhole", "label") }) - asset = tag_data["asset"] + if AYON_SERVER_ENABLED: + asset = tag_data["folder_path"] + else: + asset = tag_data["asset_name"] + subset = tag_data["subset"] - # insert family into families - family = tag_data["family"] - families = [str(f) for f in tag_data["families"]] - families.insert(0, str(family)) - data.update({ - "name": "{} {} {}".format(asset, subset, families), + "name": "{}_{}".format(asset, subset), + "label": "{} {}".format(asset, subset), "asset": asset, "item": timeline_item, - "families": families, "publish": get_publish_attribute(timeline_item), "fps": context.data["fps"], "handleStart": handle_start, "handleEnd": handle_end, - "newAssetPublishing": True + "newAssetPublishing": True, + "families": ["clip"], + "isEditorial": True }) # otio clip data @@ -135,7 +137,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): family = "shot" data.update({ - "name": "{} {} {}".format(asset, subset, family), + "name": "{}_{}".format(asset, subset), + "label": "{} {}".format(asset, subset), "subset": subset, "asset": asset, "family": family, From 7cd98fe9033c17b4897208ab7c54bda5e7e53c45 Mon Sep 17 00:00:00 2001 From: Sharkitty Date: Thu, 9 Nov 2023 15:52:36 +0100 Subject: [PATCH 1080/1224] Remove deprecated code, use avalon instances in basecreator --- openpype/hosts/blender/api/plugin.py | 25 +++++----- .../blender/plugins/create/create_action.py | 25 ---------- .../plugins/create/create_animation.py | 37 -------------- .../blender/plugins/create/create_camera.py | 49 ------------------- .../blender/plugins/create/create_layout.py | 37 -------------- .../blender/plugins/create/create_model.py | 37 -------------- .../plugins/create/create_pointcache.py | 36 -------------- .../blender/plugins/create/create_review.py | 33 ------------- .../blender/plugins/create/create_rig.py | 37 -------------- 9 files changed, 11 insertions(+), 305 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 3ddc375670..629cb4dac9 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -144,18 +144,6 @@ class BaseCreator(Creator): """Base class for Blender Creator plug-ins.""" defaults = ['Main'] - # Deprecated? - def process(self): - collection = bpy.data.collections.new(name=self.data["subset"]) - bpy.context.scene.collection.children.link(collection) - imprint(collection, self.data) - - if (self.options or {}).get("useSelection"): - for obj in get_selection(): - collection.objects.link(obj) - - return collection - @staticmethod def cache_subsets(shared_data): """Cache instances for Creators shared data. @@ -233,8 +221,17 @@ class BaseCreator(Creator): pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ - collection = bpy.data.collections.new(name=subset_name) - bpy.context.scene.collection.children.link(collection) + # 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 collection + collection = bpy.data.collections.new( + name=asset_name(instance_data["asset"], subset_name) + ) + instances.children.link(collection) collection[AVALON_PROPERTY] = instance_node = { "name": collection.name, diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 7d00aa1dcb..9267fc0765 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -59,28 +59,3 @@ class CreateAction(openpype.hosts.blender.api.plugin.BaseCreator): collection.objects.link(empty_obj) return collection - - # Deprecated - def process(self): - - asset = self.data["asset"] - subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) - self.data['task'] = get_current_task_name() - lib.imprint(collection, self.data) - - if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - if (obj.animation_data is not None - and obj.animation_data.action is not None): - - empty_obj = bpy.data.objects.new(name=name, - object_data=None) - empty_obj.animation_data_create() - empty_obj.animation_data.action = obj.animation_data.action - empty_obj.animation_data.action.name = name - collection.objects.link(empty_obj) - - return collection diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 6cfd054e74..89567061b6 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -75,40 +75,3 @@ class CreateAnimation(plugin.BaseCreator): asset_group.objects.link(obj) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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 - # name = self.name - # if not name: - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - # asset_group = bpy.data.objects.new(name=name, object_data=None) - # asset_group.empty_display_type = 'SINGLE_ARROW' - 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/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 5d9682e575..125514ae01 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -84,52 +84,3 @@ class CreateCamera(plugin.BaseCreator): bpy.ops.object.parent_set(keep_transform=True) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - print(f"self.data: {self.data}") - lib.imprint(asset_group, self.data) - - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - else: - plugin.deselect_all() - camera = bpy.data.cameras.new(subset) - camera_obj = bpy.data.objects.new(subset, camera) - - instances.objects.link(camera_obj) - - camera_obj.select_set(True) - asset_group.select_set(True) - bpy.context.view_layer.objects.active = asset_group - bpy.ops.object.parent_set(keep_transform=True) - - return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index ed47b0632f..a6c7053cb2 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -73,40 +73,3 @@ class CreateLayout(plugin.BaseCreator): bpy.ops.object.parent_set(keep_transform=True) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - - return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 949fae0f76..8beb8025c4 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -73,40 +73,3 @@ class CreateModel(plugin.BaseCreator): bpy.ops.object.parent_set(keep_transform=True) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - - return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 2ad12caa9c..aa8b297d16 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -54,39 +54,3 @@ class CreatePointcache(plugin.BaseCreator): objects.extend(obj.children) return collection - - # Deprecated - 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.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - - return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index e8b893b4c0..13fa3b621f 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -71,36 +71,3 @@ class CreateReview(plugin.BaseCreator): asset_group.objects.link(obj) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 6223e64174..6682162f4b 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -73,40 +73,3 @@ class CreateRig(plugin.BaseCreator): bpy.ops.object.parent_set(keep_transform=True) return asset_group - - # Deprecated - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process_legacy) - ops.execute_in_main_thread(mti) - - # Deprecated - def _process_legacy(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.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) - - return asset_group From 42c61fa348ca6036cef594f0f6fe6932b3c41b5a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 9 Nov 2023 16:22:17 +0100 Subject: [PATCH 1081/1224] do not check for "/" if asset name is empty --- openpype/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index e4dcedda2c..f32477dfb7 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -2283,7 +2283,7 @@ class CreateContext: if AYON_SERVER_ENABLED: asset_name = instance["folderPath"] - if "/" not in asset_name: + if asset_name and "/" not in asset_name: asset_docs = asset_docs_by_name.get(asset_name) if len(asset_docs) == 1: asset_name = get_asset_name_identifier(asset_docs[0]) From 573da36d4b12d58ab84110bbc46105be3f4e9e90 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 9 Nov 2023 16:32:20 +0100 Subject: [PATCH 1082/1224] workfile instance with support of folder path --- .../plugins/publish/extract_workfile.py | 1 + .../plugins/publish/precollect_workfile.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/resolve/plugins/publish/extract_workfile.py b/openpype/hosts/resolve/plugins/publish/extract_workfile.py index 535f879b58..db63487405 100644 --- a/openpype/hosts/resolve/plugins/publish/extract_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/extract_workfile.py @@ -26,6 +26,7 @@ class ExtractWorkfile(publish.Extractor): resolve_workfile_ext = ".drp" drp_file_name = name + resolve_workfile_ext + drp_file_path = os.path.normpath( os.path.join(staging_dir, drp_file_name)) diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index 39c28e29f5..ccc5fd86ff 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -3,6 +3,7 @@ from pprint import pformat from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name + from openpype.hosts.resolve import api as rapi from openpype.hosts.resolve.otio import davinci_export @@ -14,14 +15,12 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): - current_asset = get_current_asset_name() - if AYON_SERVER_ENABLED: - # AYON compatibility split name and use last piece - asset_name = current_asset.split("/")[-1] - else: - asset_name = current_asset + current_asset_name = asset_name = get_current_asset_name() - subset = "workfile" + if AYON_SERVER_ENABLED: + asset_name = current_asset_name.split("/")[-1] + + subset = "workfileMain" project = rapi.get_current_project() fps = project.GetSetting("timelineFrameRate") video_tracks = rapi.get_video_track_names() @@ -31,8 +30,9 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): instance_data = { "name": "{}_{}".format(asset_name, subset), - "asset": current_asset, - "subset": "{}{}".format(asset_name, subset.capitalize()), + "label": "{} {}".format(current_asset_name, subset), + "asset": current_asset_name, + "subset": subset, "item": project, "family": "workfile", "families": [] From a0da4cd17f2a0809eb2d4904a48ec8a5f5c04ab1 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 9 Nov 2023 15:35:58 +0000 Subject: [PATCH 1083/1224] Changed how we get instance group in the validator --- .../blender/plugins/publish/collect_instances.py | 4 ++-- .../plugins/publish/validate_instance_empty.py | 14 +++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index ad2ce54147..2d56e5fd7b 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,4 +1,3 @@ -import json from typing import Generator import bpy @@ -50,6 +49,7 @@ class CollectInstances(pyblish.api.ContextPlugin): for group in asset_groups: instance = self.create_instance(context, group) + instance.data["instance_group"] = group members = [] if isinstance(group, bpy.types.Collection): members = list(group.objects) @@ -65,6 +65,6 @@ class CollectInstances(pyblish.api.ContextPlugin): members.append(group) instance[:] = members - self.log.debug(json.dumps(instance.data, indent=4)) + self.log.debug(instance.data) for obj in instance: self.log.debug(obj) diff --git a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py index 5abfd6dee8..3ebc6515d3 100644 --- a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py +++ b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py @@ -13,15 +13,11 @@ class ValidateInstanceEmpty(pyblish.api.InstancePlugin): optional = False def process(self, instance): - self.log.debug(instance) - self.log.debug(instance.data) - if instance.data["family"] == "blendScene": - # blendScene instances are collections - collection = bpy.data.collections[instance.name] - if not (collection.objects or collection.children): + asset_group = instance.data["instance_group"] + + if isinstance(asset_group, bpy.types.Collection): + if not (asset_group.objects or asset_group.children): raise RuntimeError(f"Instance {instance.name} is empty.") - else: - # All other instances are objects - asset_group = bpy.data.objects[instance.name] + elif isinstance(asset_group, bpy.types.Object): if not asset_group.children: raise RuntimeError(f"Instance {instance.name} is empty.") From 9607db50f2fce387860d94bf7563a3e841ac1089 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 9 Nov 2023 15:58:27 +0000 Subject: [PATCH 1084/1224] Testing: Ingest expected files and input workfile (#5840) * Ingest input workfile * Ingest input workfile * Ingested expected files, workfile Implemented LocalFileHandler. Test name added to structure to separate files for each test. Removed superfluous `files` to keep other Maya test working * Missing time import * Hound * Skip directories when checking folder structure. * Update tests/lib/testing_classes.py Co-authored-by: Roy Nieterau * Updated integration tests documentation * Ingested test files for Deadline test in maya * Removed unneeded files * Refactored name --------- Co-authored-by: kalisp Co-authored-by: Roy Nieterau --- tests/integration/README.md | 63 +- tests/integration/hosts/maya/lib.py | 31 +- .../maya/test_deadline_publish_in_maya.py | 3 +- .../test_deadline_publish_in_maya/README.md | 17 + ...test_project_test_asset_modelMain_hero.abc | Bin 0 -> 24787 bytes .../test_project_test_asset_modelMain_hero.ma | 1208 +++++++++++++++++ ...test_project_test_asset_modelMain_v001.abc | Bin 0 -> 24787 bytes .../test_project_test_asset_modelMain_v001.ma | 1208 +++++++++++++++++ ..._asset_renderTest_taskMain_beauty_v001.exr | Bin 0 -> 3608096 bytes ..._asset_renderTest_taskMain_beauty_v001.jpg | Bin 0 -> 77035 bytes ...et_renderTest_taskMain_beauty_v001_png.png | Bin 0 -> 563725 bytes ...oject_test_asset_workfileTest_task_v001.ma | 525 +++++++ .../test_project_test_asset_test_task_v001.ma | 525 +++++++ .../test_project_test_asset_test_task_v002.ma | 525 +++++++ .../test_asset/work/test_task/workspace.mel | 10 + .../dumps/avalon_tests/test_project.bson | Bin 0 -> 4899 bytes .../avalon_tests/test_project.metadata.json | 1 + .../input/dumps/openpype_tests/settings.bson | Bin 0 -> 914 bytes .../openpype_tests/settings.metadata.json | 1 + .../input/env_vars/env_var.json | 11 + .../input/startup/userSetup.py | 13 + .../test_project_test_asset_test_task_v001.ma | 525 +++++++ .../hosts/maya/test_publish_in_maya.py | 4 +- ...test_project_test_asset_modelMain_hero.abc | Bin 0 -> 24647 bytes .../test_project_test_asset_modelMain_hero.ma | 1186 ++++++++++++++++ ...test_project_test_asset_modelMain_v001.abc | Bin 0 -> 24647 bytes .../test_project_test_asset_modelMain_v001.ma | 1186 ++++++++++++++++ ...oject_test_asset_workfileTest_task_v001.ma | 476 +++++++ .../Main/renderMain_metadata.json | 275 ++++ .../test_project_test_asset_test_task_v001.ma | 476 +++++++ .../test_project_test_asset_test_task_v002.ma | 480 +++++++ .../test_asset/work/test_task/workspace.mel | 12 + .../dumps/avalon_tests/test_project.bson | Bin 0 -> 4899 bytes .../avalon_tests/test_project.metadata.json | 1 + .../input/dumps/openpype_tests/settings.bson | 0 .../openpype_tests/settings.metadata.json | 1 + .../input/env_vars/env_var.json | 11 + .../input/startup/userSetup.py | 0 .../test_project_test_asset_test_task_v001.ma | 476 +++++++ tests/lib/file_handler.py | 122 +- tests/lib/testing_classes.py | 70 +- 41 files changed, 9346 insertions(+), 96 deletions(-) create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.jpg create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001_png.png create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.bson create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.bson create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/env_vars/env_var.json create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/startup/userSetup.py create mode 100644 tests/integration/hosts/maya/test_deadline_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main/renderMain_metadata.json create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.bson create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.bson create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/env_vars/env_var.json rename tests/integration/hosts/maya/{ => test_publish_in_maya}/input/startup/userSetup.py (100%) create mode 100644 tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma diff --git a/tests/integration/README.md b/tests/integration/README.md index eef8141127..7b9b7cd706 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -14,23 +14,52 @@ How to run - run in cmd `{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests {OPENPYPE_ROOT}/tests/integration` - add `hosts/APP_NAME` after integration part to limit only on specific app (eg. `{OPENPYPE_ROOT}/tests/integration/hosts/maya`) - -OR can use built executables + +OR can use built executables `openpype_console runtests {ABS_PATH}/tests/integration` +Command line arguments +---------------------- + - "--mark" - "Run tests marked by", + - "--pyargs" - "Run tests from package", + - "--test_data_folder" - "Unzipped directory path of test file", + - "--persist" - "Persist test DB and published files after test end", + - "--app_variant" - "Provide specific app variant for test, empty for latest", + - "--app_group" - "Provide specific app group for test, empty for default", + - "--timeout" - "Provide specific timeout value for test case", + - "--setup_only" - "Only create dbs, do not run tests", + - "--mongo_url" - "MongoDB for testing.", + +Run Tray for test +----------------- +In case of failed test you might want to run it manually and visually debug what happened. +For that: +- run tests that is failing +- add environment variables (to command line process or your IDE) + - OPENPYPE_DATABASE_NAME = openpype_tests + - AVALON_DB = avalon_tests +- run tray as usual + - `{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py run tray --debug` + +You should see only test asset and state of databases for that particular use case. + How to check logs/errors from app -------------------------------- -Keep PERSIST to True in the class and check `test_openpype.logs` collection. +Keep PERSIST to True in the class and check `test_openpype.logs` collection. How to create test for publishing from host ------------------------------------------ - Extend PublishTest in `tests/lib/testing_classes.py` - Use `resources\test_data.zip` skeleton file as a template for testing input data -- Put workfile into `test_data.zip/input/workfile` -- If you require other than base DB dumps provide them to `test_data.zip/input/dumps` +- Create subfolder `test_data` with matching name to your test file containing you test class + - (see `tests/integration/hosts/maya/test_publish_in_maya` and `test_publish_in_maya.py`) +- Put this subfolder name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)] + - at first position, all others may be "" +- Put workfile into `test_data/input/workfile` +- If you require other than base DB dumps provide them to `test_data/input/dumps` -- (Check commented code in `db_handler.py` how to dump specific DB. Currently all collections will be dumped.) -- Implement `last_workfile_path` -- `startup_scripts` - must contain pointing host to startup script saved into `test_data.zip/input/startup` +- Implement `last_workfile_path` +- `startup_scripts` - must contain pointing host to startup script saved into `test_data/input/startup` -- Script must contain something like (pseudocode) ``` import openpype @@ -39,7 +68,7 @@ from avalon import api, HOST from openpype.api import Logger log = Logger().get_logger(__name__) - + api.install(HOST) log_lines = [] for result in pyblish.util.publish_iter(): @@ -54,18 +83,20 @@ for result in pyblish.util.publish_iter(): EXIT_APP (command to exit host) ``` (Install and publish methods must be triggered only AFTER host app is fully initialized!) -- If you would like add any command line arguments for your host app add it to `test_data.zip/input/app_args/app_args.json` (as a json list) -- Provide any required environment variables to `test_data.zip/input/env_vars/env_vars.json` (as a json dictionary) -- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id (file must be accessible to anyone with a link!) -- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded -file, provide md5 value of zipped file. +- If you would like add any command line arguments for your host app add it to `test_data/input/app_args/app_args.json` (as a json list) +- Provide any required environment variables to `test_data/input/env_vars/env_vars.json` (as a json dictionary) - Implement any assert checks you need in extended class - Run test class manually (via Pycharm or pytest runner (TODO)) - If you want test to visually compare expected files to published one, set PERSIST to True, run test manually -- Locate temporary `publish` subfolder of temporary folder (found in debugging console log) - -- Copy whole folder content into .zip file into `expected` subfolder + -- Copy whole folder content into .zip file into `expected` subfolder -- By default tests are comparing only structure of `expected` and published format (eg. if you want to save space, replace published files with empty files, but with expected names!) -- Zip and upload again, change PERSIST to False - + - Use `TEST_DATA_FOLDER` variable in your class to reuse existing downloaded and unzipped test data (for faster creation of tests) -- Keep `APP_VARIANT` empty if you want to trigger test on latest version of app, or provide explicit value (as '2022' for Photoshop for example) \ No newline at end of file +- Keep `APP_VARIANT` empty if you want to trigger test on latest version of app, or provide explicit value (as '2022' for Photoshop for example) + +For storing test zip files on Google Drive: +- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id (file must be accessible to anyone with a link!) +- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded +file, provide md5 value of zipped file. diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index 04ddb765a4..ab5bcbbd36 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -16,18 +16,25 @@ class MayaHostFixtures(HostFixtures): Maya expects workfile in proper folder, so copy is done first. """ - src_path = os.path.join(download_test_data, - "input", - "workfile", - "test_project_test_asset_test_task_v001.mb") - dest_folder = os.path.join(output_folder_url, - self.PROJECT, - self.ASSET, - "work", - self.TASK) + src_path = os.path.join( + download_test_data, + "input", + "workfile", + "test_project_test_asset_test_task_v001.ma" + ) + dest_folder = os.path.join( + output_folder_url, + self.PROJECT, + self.ASSET, + "work", + self.TASK + ) + os.makedirs(dest_folder) - dest_path = os.path.join(dest_folder, - "test_project_test_asset_test_task_v001.mb") + + dest_path = os.path.join( + dest_folder, "test_project_test_asset_test_task_v001.ma" + ) shutil.copy(src_path, dest_path) yield dest_path @@ -36,7 +43,7 @@ class MayaHostFixtures(HostFixtures): def startup_scripts(self, monkeypatch_session, download_test_data): """Points Maya to userSetup file from input data""" startup_path = os.path.join( - os.path.dirname(__file__), "input", "startup" + download_test_data, "input", "startup" ) original_pythonpath = os.environ.get("PYTHONPATH") monkeypatch_session.setenv( diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py index 9332177944..c2ef342600 100644 --- a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py @@ -24,8 +24,7 @@ class TestDeadlinePublishInMaya(MayaDeadlinePublishTestClass): PERSIST = True TEST_FILES = [ - ("1dDY7CbdFXfRksGVoiuwjhnPoTRCCf5ea", - "test_maya_deadline_publish.zip", "") + ("test_deadline_publish_in_maya", "", "") ] APP_GROUP = "maya" diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md b/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md new file mode 100644 index 0000000000..929bfcd666 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md @@ -0,0 +1,17 @@ +Test data +--------- +Each class implementing `TestCase` can provide test file(s) by adding them to +TEST_FILES ('GDRIVE_FILE_ID', 'ACTUAL_FILE_NAME', 'MD5HASH') + +GDRIVE_FILE_ID can be pulled from shareable link from Google Drive app. + +Currently it is expected that test file will be zip file with structure: +- expected - expected files (not implemented yet) +- input + - data - test data (workfiles, images etc) + - dumps - folder for BSOn dumps from (`mongodump`) + - env_vars + env_vars.json - dictionary with environment variables {key:value} + + - sql - sql files to load with `mongoimport` (human readable) + - startup - scripts that should run in the host on its startup diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc new file mode 100644 index 0000000000000000000000000000000000000000..d9e54259960efb658348c78adaa0f9ae0958083e GIT binary patch literal 24787 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 MH=R?xes^8}8yK4hrvLx| literal 0 HcmV?d00001 diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma new file mode 100644 index 0000000000..9ee588337e --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma @@ -0,0 +1,1208 @@ +//Maya ASCII 2020 scene +//Name: modelMain.ma +//Last modified: Mon, Oct 24, 2022 02:57:47 PM +//Codeset: 1252 +requires maya "2020"; +requires "mtoa" "4.1.1"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2020"; +fileInfo "version" "2020"; +fileInfo "cutIdentifier" "202011110415-b1e20b88e2"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19044)\n"; +fileInfo "UUID" "A787A358-4FE7-6E55-0C81-61BFEB0C2726"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; + setAttr ".rlio[0]" 1 yes 0; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc new file mode 100644 index 0000000000000000000000000000000000000000..d9e54259960efb658348c78adaa0f9ae0958083e GIT binary patch literal 24787 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 MH=R?xes^8}8yK4hrvLx| literal 0 HcmV?d00001 diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma new file mode 100644 index 0000000000..9ee588337e --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma @@ -0,0 +1,1208 @@ +//Maya ASCII 2020 scene +//Name: modelMain.ma +//Last modified: Mon, Oct 24, 2022 02:57:47 PM +//Codeset: 1252 +requires maya "2020"; +requires "mtoa" "4.1.1"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2020"; +fileInfo "version" "2020"; +fileInfo "cutIdentifier" "202011110415-b1e20b88e2"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19044)\n"; +fileInfo "UUID" "A787A358-4FE7-6E55-0C81-61BFEB0C2726"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; + setAttr ".rlio[0]" 1 yes 0; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr new file mode 100644 index 0000000000000000000000000000000000000000..b137a46dfd7d763017b172f9b8df014f7c82f281 GIT binary patch literal 3608096 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 ?ASAMn5K#SWdl_B$>_K>>jd%zl?2<+o_1+f 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

taJK*HA)OcIKdSn@)n%l&uon{)NnnO9E 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⋘ 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≤1a?Zg-Hw%fnM-vj}e$av=X0Ns`nszF$GI=Qzbm+zpS|mdKay{UcxSl@Vf%{azNa zOQfHE@WZ15!t`6(`$zCyQAfI8!#tbi=^-CED7t7QC7O~>1Vr|a_cTGz(LR^yChnIpD~Z! z18a5{h(Y_$&{#x)p6Yi~e%?R7Q2*i_5)x2kU{N60?c2#R_?6r#NKm&9^9-!Ow*sm7 z0OOum%Q6M|#Hawm1_*ElaTiG(r%ggkD8DSNv9|k~b0fB86q`yE!v{Ol1qiiyt!0_Wkb5w$LcyqU)S?1_MTx(c+ z?;p)V#>spgy4fdq)k>2LE{QW#xhAWmCwQMH*Le4+qRV#A$njQpppP~aoaV^J=!-*m zB}ewFeTPlr&n+sWbg#c^&EquT zHXt7k8E-&ZN10wj#8*+mVeOOCrQzIQ(TP5Mrx0Jf!8(Ug zcmr4^di?_!kSm>_VO#+}*kX}y_|c>@q4fFm>+RVMuQ{U{*cxO8w2$zMw)s5=`{I^?dxo8&2PEg(8HPfig0oCJ zA%M8@Pb_jl?@cT4o|^{g?q1=&FwPp5Xa;qIyaV-C|02@@Z^tniT5pUNUxVOeX#7ie z`x4-;i*_!<>IBE+*2e?C-S3Tge8!_uRjAsC5lVr1LV2Cf|BX*nlew>K8sY6E~6juu7ASKWJVevLSoFsyWK&fUHipjJQFRz zP7C#HKQPzFD!!y@PA=#PF}YEme6)qDvq=NOo2O|$8w?PaxbvgH6U~CFA0TDRK30VP zHUR6t1_%5P+Ux(pi$eEk(?tBtpDjhXFYLxH!^5)*67gXddyV0P}gQ>prE)CLWWq;ZfI`2un@o&M1M&+Z~14v)!@AU=U%p-mVRL zIwDn)d@oQPteZO42OH6J)qOA^*ARlOl^9vBrBMf}q zL=V;>I0VS{gcwF062CC-u*db@(F9BqQq9^sD~rwk{o`AwW=WIs?hUSR(m3}Jn=;wP zj8fhV1{M2gMKX9=L5zbu=P?W`Xa>I0MKPf#?kPIFwhD{ET zKI7^rqBdZF;L@*qR;(d9E%_C|=|25#H)mngD__uTOiG zE%L=8F$ss1G_m`#D%1He)xoG&3BDR-c-3ILr0lW5n2twtE*9*4bhtkDW$exRIQ_<; zl!~8lcvM7qIuCE(peTf_f?sHq8S8+ zexz%p53r+Zv2|n_h1uM1qHS(ac>NI^+9k*g<4X^4p*+_GREts=g56VlULF#!(JZ+G zl&rT;Lb~2>&v)Y~b5p;59YMd$(iAnaGP;LkT88=Py6)%;DJThT*TMUl@C*7{H$?|~ z1hqGYugv`Nzdlr8k7$-={b+axpC5XG%`vb;IXEfSz~2vYTAo>&X^z6N7ZP9_cY?A0 za{jw#{X(9vYKxJpRH32~EthqK{6?NHJ4IrWK{RF45onn4JI9S4QLuJ>qgnh>TO$7( zGPw7HU(~@OwJq}xfPMXU)f;|JYpPm-$r5a*4%es(+s>_edj{?mgvHROurEMo)S#!| zE`<}OD_v_%>=+oXwu}z8?t`lYEz(bZy9HF{*ClDO5!2o^alJ*RRoEZ>Lf}c6#92!1 z=t7%2&I8g!jUz(uu26NCF2f{}?-b1{A-nE6$Img04x&YzK>B?u0N*U@QKYeCws7Ms zvPjLes>stPtZv$dV6Ek_=Q@DEO?8x~bd@%kBcN5i%{44mqtK%@N*_Yt=hvK88{5My zzg?DElkP;T56LwAcZmdP4bpUrMW^n(m{F*Bc%^F-^Adgy6FDPhXD&Mt4JEeP^d0C& z&bN7;{&CxI{L5Y}=81N%5bya>50EYLK`nw4h`>{1f^9n4@p(-=_y*x6bF}@Q*GQ1h zBa3#}mI3OSN}S^}2nc|2!-cnyzi*A(FR27OMDyCuG4}ozaON8|%TT0&Yq3q>`%{f# zo)uT`@_2H=C{y+27t+tqD)FTqZO5(6q0OQTwK~a`_45e`{S11F_VJ6VqSqXOVeKA~2T`z_dD1f68Nxl6vu|Qcfx#)F zSj-LTPbbS9^-@M}tl2iRU50CAi+E5#~o zE4LVv9N@-&3gVtqK|z38vs=a_KIL_gN4!j_3hp$b&^&01R-`P{=>Dl$67Q@xm+U|>>74}T9_MS2(51<~)+SyfTP`V5e~4&}FEBwqzPU0%r^8~Mhz%-9;{ec&41b!ph1Ag#|JDexT z;g67F&p938s>l%7n%P^cjQYOBZbW?}ddYDns(FP9 z;CF(pAh%<5AOHZsIt+c!K6HTue_f`eTFg4~z0N(*HhO}wP#t$yuIdN_;z^;R`>RTc zd4g;;e`l!kAP3|lWn^e!kamcCn1ZUA&AFBOIe#3e^R&36}1mI`I?>q0UebV@w}FWlE0WnfjrQ%Ebd> z`Z>=}b{UjosoH3_UXh7Ie{(H1$ztmCP=7SXjz-4goo zrIJbJ7l?E#bqaP7C34kr)yl(6;7^9>!S0?uA%D%Q20^~TBE5EzerBr4PI3E~K8|}x zq2??ttJpyKytGmItL8CIcD-<{f$`!?agS@F1`g#3v z|KGzfwTBns${|9cA=)<1@)6QD+9pOfo4->xJ3~L!=nO@pfOckzUZBG*2=Ve59pk9` zYqRJKE#_gO8PSHHms4hs!0Y2YF5xB+FyFLjJ;=*BD)JHhd9&yq{sgmS%nNX!t;FvN zgKrJEEAv#DI?=ZO{sf4Zfo`*m3zSy~)w+r1KVt471bYEsx3FF=qHT23(9eEeX3?rm z>IF0t<}qvdwW=qWM6*IohQ$*&ng!kg+WBa=v-BkU2I*zr`uQa*+xgqSdwRrJE-`np zHn56zF)y89#G2x)Zj(PDcd-aHTgCns`*-y%B8(I5q79-6mfKiG+F53F^BY8g*0J`? z(}?$7Tt?{|q^u*%ZR(XItG@nCQWrqRnFH*1z(M}%m2SZ@rR0+~$+;T1XQPZ^mi?S8 zv$cxXkPJ(2S4;y(gq2FB$qBYBqtVvfeMK^wh1(>QQ-Jq5>VM~V@x}Z#KE5FfgkXx;l9dK`Z!amPvY) zGlLwJ^1r0pgx-PQK*XB{IBTWgZUo!2^xA|9cP+k$k7S#u#FzzWSBHebBKVw zjkcYo5t%ehuIILlDp4~{U7?xa8DRca!_zy&3<3jxovOV`f_C-@9BXb+3VocV$u#l5 zKfxoUW4Lx+k^WDYL*%cGT7~<>i5AE!#+j~xL2kBbK=&X}2)ht3{Nr~1u~y1urU@K_ z=cp8eUykCfgt%oGhuP_t1~{80JimfM7HKt!n8hVprWvMah_}k+ z{wVL|4YBR|Hq6l>oMtldWr>=2iD|Gw;tgb+X@f{NyG;mpSFzk8KF~eD!z__zc7@bA zRIUpCI^BSEWQ*WGx&J42a7CLl^x1~*U=S}wT7chmvjg4NNCr4ZS*&8A?`>j0U(rq` z7~?EaPoE&xiFI;FSJVs7k=Ka_I3;=={j~FICBz$Z&e+r-B{pKU@lrjS3tYZZTuYM8~=%iS};PqOmUMZTn; znP$C%?Ewt);u*L|sX*gD>qkCMHvH^gQFK25}k1tV1IpHpD z5Rj}%*Zr34W5Y?Bnj^&d|r-KEn8K{sRDNg+4w_;#RTKbV_Ad2UPRXwh89mzDF1rNH1V!i7g^c z;=MeR41Y4iHIh*l)1>cpVNNJ#qbzYQw1Y4Ye7z6=^xa0`Oshsg#~{th1d{@dQFiPt z!cEz_6%xB((ccHy{}m6Qo0?>Hir>OMMa|J!Bp>1ysgH8vnc(fyN|i59H-dbCe4M6Z z9drnNM)LHt3cW_fJ}uC+4SIl~UIe|y+oD`y9FDfpEC>rEAHzLvkm(T=Z$3nI2+T4{ zS9=Fls#Z@cR{HPy^|F5C{+5|yXY0R&y8>k!zQmcw3v+0c?-7wN16->V$+zjHw~7M4 zT7@6s{r+ArW|ln9fOMR$U#^0^9&f3iHbJYM7v!;tPk3^GnX1z*Y*?^~m93OwQ7?Ik z%`_5a{PF(Z^^Y^bKO)|C^7nHyOkH3{SyN7AX}~{ZYVVMIKpFi^GnA}GIrRxzB1OGE z#)Q1ZI!4+Z;3X}P=Mdjj|DoW>BN2qxrI9xXf#O3 zSN?bPe}ZZb@zT!=XAkOudJg!#S+qkys&QYHZ4BUro8$KxikWE;zXjSuPR6l1Y6=D|8nj_wWI-{Jub zQ{~!WE-HofGO_mOuwnKEI_9a1#KNr>Nxx(tAdV67PJnN`Jl{a0yoeW|-zOR2j)mJc zDQ3y|OLs`j@|SUr;gqX{#szDQb86JeReQO~R#-Y$Xw$SV(1_>zS^l06`ras~To+<> ziU<0(K-BQfJaG%nB(vnNEKjL&jX1!kk8K@avATzE2Pej~QO?~f!qqs7e)Jag8n#M8 zvZh*&Wszz=%EZ?%)UAhOh1xba!$7b-#G^>bpvc9OVD<0$mT@E8+=G^hiwrv$GYqNv zszv5861Bgj>XlgA0~~HK*Ri8LN@VI}LtG{q9HK3wA5m&0Wphvuy1#rA z{VKT`+HHLB=O>5;iBr@qf=fireEkdnV2-+vyH%KT6@U5`nd5i4mV1P9?h&a`H0fHp zX|Qt_<0@0M9pqX2*EO0cYK5}DrxR=$XZQ!m)pQB=^Avv*?X-zt?Q@N+Q{(M2Pi_+1 zz#-nK;otcJ1iFr;SY4u9pz7%NWi!HSmY8`6^E_WO#bBB`&32YN!ZN~BvK4=?i?^S9 z0}pS5x7#XOCHI%q-}8BTR4ZELv(yqz%QROAzSk}?aW@9I-651o;~g!a2gI0Wf0?jK z9AOu3R7;a@Kz!ZA0e{iU?`Dc{1_!+dino}dwMvRMzl0r-(9966i?O&z_i;W$)=DYS zD^}Ld{d>M^@GhQt688QO7t=WW?E|c15Zj1iG4W1-D_=YM4%ZyU<{k2vl>omptS~Er zU7D#U1eoh`c|U8iWq(hmao1qSaLC6^!d=|2-MhHevICs7Gv$i4Qf@&HP#Z*_aUTHK z#_JH+!BZ{%vRbOVhf|?&ifR%61|r=0t+A6|xb+#FVbU%NZztWrDi-q5G2A%CJfTW1 zL-P?C_LgiO!J?JMs$8Y#nr82@2Z5`&iHMwMiG|2eUwP!D-?w;vi?n~ zb+Q>&^y67(EQ3aL$uVH(Zs5Ie)<4V+M$ zLApx_)jafteAfV1vY}#~v*$X+Cb2;x*3BIC$F+4#f+OXWeT-!ye?8FK=k$L7muQtK zRw>mhqHW999-$=~f9=;#Kf^%0lBo%GeulmWW9}#48e&|a%GSOFLVskQOVaTPeErB% zqFk2fQ!b6N7-zbKh_Q|{4e%E2-9xoWm}DzZB%Ng$`5Zsq0u=NW)-r0Bh;lMZZ-m`8 z=^pAgpG1vcc&cuM18>_YJkz3M!WEcpbc|WCdXctKnryL4bct$<#0}I5Mx!|Pj&U^P zYmhJflt5>l2+I`w3;1QOdZBWX@#pxEj|+4}+nPo1x3Di4*mnTaOr_d3zDil7^VxS3!!dd#!wGuDB+GQquIJSI%P_X6QRyhwJjpIu;NWn z50Un*!sy4Ja~}YZ`lXwlWXdqPMd0O^`QxWG>}`^%MSQ7psB^aN8%UI$b7+&emmmK2 zBm?yvn9|wrh<}iS0hXuM|C*{nKPx5oM zy@G%N!)y%Gl8vpBV|2+@yEvXce!d9XZb1QFTVze5)N2I0FSqWIhI#v_I6KQk&HP~C z5-l^t`^4VCZ6Ysk^8|YYu-9ruwku2ENzKEcJxW9$vW&mf=GFa66n>@m8ZSH9+Nu~@5n=owm_9Fnyudbz4_ z$8yD=*7cHNzd!3g0AQ4|NHIgpHe8`lsBRaJC1%DY*S=-gpg%{y^X%+9^(*>Ifr>rq0QHosV~~_S^WTj zVj0B(`qh`^PJXUgjm#qq+i3Lt8tF)nX0a`FFyKh-0YSgeWc@Tl%cNTA7z^tt&gNFt zJ4Di{Z}pv$&7v$L@MqWuh{q>5l$$J*5>@nr+Sz;P74o0P15VH?lx>ql+tDwePDki~ z0C%X)fo8GYTs_PJGYSLp7f+Fzxu)sCfF$dY z4<v7BC(O|V84+%@J| zsH;kPvPp){I&qS@MD;bgaOW(cTS$mYsqzY`ZS^c96wIM(}{dN#l$(NlXnm2<00O3fK4=K8F7iqHtgpU;l4=r`SGV%1OTU4!ahfv zi`PCwG>cEN-9kY<{rZ03V|4RoqeW*ot~Vjo65IY8}VxIzO4Nij6Z;TViIFHsw1 zb9bpyyhLB8x`QU!sTB9{arSCeBwU;&|2+SDJ^DG&hfGzXgIp2hV}M7sl13Hv={ocp^)>xqN2pMZNO_#5E1Lqnn*iN`P$nkmi0RWwB zq>FR|=CLTd2}Z1gD!DrtnVKA(3#1-ii}>!ZM;I%l&)|7lOXQ<0u~s{HzTT&(nf6Z~ zsk%ya$9Tb}HPYy3ppe_Rr&zP-Wg6g+5vFh#!Y$`WpXW!}fIiCAUE(Y;VC;Fh!ag*J zJwa*}^7X9a>ts6z)2`SCcm+zgg4`AB9-&OrEfM6JVr-sZ##o?k+awYn+Xm>A?%>cb zy#OB~ZKLcEu9L=@hPtU1f1W?V=l(`dg}t{}z&K2J9-; zGQx~wYK-oub*eVsIL$KPIo3|JagaOIjB0w8qD(2^C+Wg50`@u3Q=YzM)H118M4~3` zf2ex?Z&9p9yjd$ryT{VU){SwA@Zjur4}C;{vnJa}y}9_MQBbk^Ta#XaT9!zw?avsi zUT)12A1nOr-(uRi&Y@a)%wzLZ?t!#3d|fRfZb9qBLTz|Ef$smrf4NHe%2MS5jVB24 zrWTQO1En%AzdtoVl@hY$cVN)hADQdKh?fx#3Fi5lNv6c>5e}6KNv^&w)HCaZg{lUb z;vE7Vy{wugfLF$q*6(AqhcLaY<_VwYy9KEh()Aga9^hTV1^Kr~kgO(Z#5j>}TE^PO zuHlJxT|umpJwpUp2l`O1Qq00`^DF{^ry5btB z{iovz;v%JsXE&Enf2Xi&i9yyStV0k8h%)!cn4n4OXVrB6tD!1hI*~O#Fn()6U%uep96^FjOJ?@T^*Vfke28 zvsd>W{B^Si%40sPh`q@CL?_za$*zd%X4Ql><W zVc@qu4&yj?-+zDyl&aLr&QcO=z9H0qbqRlms*|@3_%d&o#NMZwHA_o5Zx>CwgmPe* z3k>l9I?Z4o@B*8yd4f^;Qg?ogwy-%)GgL1k9`!|KRe~W%D z={l7P(jAn&1*&utKQEQ4A1Ppa;l5|sJzufsx5=zh=~v|oyzHf#{X*a#6)S4wA%O~H1n*c4;As&S??!*Iu3?Yi#;8n2+h?|*8| z=g63cySQY_DkU#qOH49L+73QoDg)47865Ac9`NtFtNgfVWKW%BJ=l8is?o^~fIBpf6Xa@8V=9F%551nihV)p*B_Rlf{Ss8^2`EG4xY33Tb8o{MyCVvBm56G4(TucVlcEg1Ur_ zQ*kaGLP``vTvE)Xe%Z$FA+z=rsc?^duZ2CAX@_|cs!KG6xh31s$xzEnHTCq*(W#as zS@|dWp&o;L)k-pz7Fnz#u+ClmaE{1lFt15gtEH8HeOZz(Y8F+h0KYgu6YEQ{eg?e) z7wm#LWtg;#3w74bo@A(&JwY=}D^=$2bPO+1K{-n|_$~HN+y?;mv3^Nw_XMRf?s)Z}X-pNBgC=^aWnatUb_W*YC~mi~1OKO%mL`SBiTy+|w5^axU| zKt93Oqn(5&0 zI?A;p+(uble3qDW)0~1WquZs;{+bJpE)3%}GMUy*-|I!Zy;r{2MSX9pk>TmCk=;iB zCtqIxK%lci75OCC`5D|H4tIYArJe5;#Vt^@NwjH&hQD=^1p;6drqD<^nQbaru|b^R zvW-+O!!S<2&>*Q-FhcXclXb*g-}=k+1M|{wEIfb6z41aXSP8L#$D+ z;fgngIYA$Yjk3-VO&$}f+UOzZY#(EE4Rn4d)ns29qe?B8YGZbaxH*_Kma` zZ7x@&T+q!P;7l`Iq-Yo1#+Rx2C;V^+@+GNSwqfA+4b8HCBy$P7@AHH+yc zNc{r-2Yjb+$@Uz5sB4!{{ghD8N|{hI%5AvUSfe62(bgEpLXBy<0gh7y+=Bu&<7}rG zgk6b7f*t4J7IEw?nd%klD0k=!tAHOxip2#+-MpBG8b$JHsMr5QKTq%X#sR)|VcbKc zWAz-|HJ}&rbs!X)arO?EgftxhFyx&>%o$p`mT|gj1?)w)&>b|~Dc68mOqH5rDBYBK z7UfL6CJEEs|C#{pQ2m zN50n05dXD_w?-ycD_`-`UcPMi%R88SmTIwi0{xWXUu*9f${}Wxcn@#AWTpHB<2cjm zmkfQ-*MIf}xQ1IMBieww0RRlrF%MIX>Lu$XM_8qQnI&@fp`3B`!`+~sK0wh=?corw zzk*6M*kp^=r$vSRuPZ5N@!IwoGye9B0lnsSvM{`8?eqJ;`cBFhw74 zV}YKt!`Ia_fN?UxhP9V#e1`xC%+?owp;!FNN|dY6?|gNx5s5~WOOz#~SK*FZ*m+um zRQ2Km6t$8QRO$(w(=-#%j~6ib!&5}ahtKoP5;aQWZAa)My+upAzb?=|L)A&y1v|&x z!73L9*rEXVy5fwOr+%8n>!#Wh>}VITjn0rt*L4V+#6G?E2`$h8gSK<|y5Ze=`$t;t zp~cySnxP%e(0-nee!NAzi)oU73){wT9`^!Hu<;5Z*sfkNL2i&(C4Yc_f#4isko8Nc zgFV_&qS-5qeURtZmsOBAy6Lao-|Nni#hZxN87DOJkI_2>=%)9vYh;Gm{)zuJ(swY- zV@DX)KM}5O;H-b@W>+ZWYhoS5S0#f(UkG)n z7JfWvl&O~Due8eqdno7D3g&6nNhetV11A_DUVNU9ablmCt4%YJWSVY*vBTdb*sY(* z*{52-Kj0LwO#6N}!ef?2HMdRhy;iY=a{%^Yp29Aia|-c1z;74*8l86b>#u7x_0(H1 z#m0tjiRM3Tp#RF^w6j0Y|0VqjLA)Ph>k=weX%#!lB;1O70C;f!U|4-^a z=tq`G!UJdMO4)n(9w9VSWiomhtfLUGgY0{-r6$c{)zUVh)=ACM8*q-sH5b+awv70ZoMx%%6LE|C6Y@SmLl6Aa;wL+r1h#Os>*xVt(z ziscdwa5vhy!)(vs(YAlim)ive+xxkcOJQ$+WP190`lFo5cb_AUu_GRX++D%K+#L~= ztA^Qi@uz9yZ{crh=KaWI9?R1Ctoj$w4*>C2x<#U$ej?Kl$>s;d8eW?I8E%bKkxv5Jz}{$lT3&woqYZ->!=gNI;m~43bi0djZ*oN1PiNBQ??6aO%V#Y2GJ6EmR_)H<2Qn>I~eSJ zm^;Kvqzl4Lt5~*SljQEN+POWvCdohmx~X&ICx|8TWD}2Y>eXnQ5=F>6wyAaGP}eYL z`|z4?`UTYMlJ&qZ7pO57QI?<8zw!n8UbMj}*eQ`{pMAVRP@;T>;|%2(WtoC;Qm9w8 zWDolW6@Px5W`dDwJUD0vo3o33j9^>AX*>HLv5#G>k!`U#v%s!dIyhsX_&1| zD8*uk&6!)l6bdk{X=V$q>Bc-ZZ z`N0m$thB>@0-1(AUro~d9a!gjIc#G2S|M*hAC?$A!hKvKtqXL*JPEgci^&!Z(ScTWmL-^V3zezTO^M{?>m^y|Ye4{O z=JoU3LIZB`H$JCdsLe1V+POd-`OrPMMCTN7ly(=bz~JahvTm@8XCUM9w|cig!NzRE zLXDqBZ@}GrWSdsegv-chDdvv|m?u`zSMaOYI9Ih|C5qpA+l5;>H*oT4T+3(Dg!xfpY@6|+?P1iiR6UZG*E!&B4+su$oRWcqRCdbs;bXkX7^ zPL}o`nK?Qs##aay@kbbtj|`KjrvV=2iU?PhabA9WU4L$SnJ0Ws|06`SE#xECL6qGb zb)`J($PBGrRD}Z7yml_-f@J*%5ZFtpvS{-PX}F_qc7R8eU6tGw!Uw!ne3Ws7bGrFH z`VbG!MuHLeOOb9L%Q}^x3(cfcB*PfvSQp=C=z{GeYdiQ_dF%99n)RX{0mhj&iK(U_ zx8sasj1sl-4ek+jqP21)YcXb7vU24g2rCR~d4tUQIXOD7V3tX0`DYkJvpkb3C0ht1 zRD8orjHs919vK>026vE&rk~NzGBV80Js{W;>g^m-FB9ew;dqGJ$syJ>%UvO3y&DJGd$=3ah z{s+h{f-8hT_ah9bhi7p4$}wi1-dd?5HTtO^nN@P3&JGch_1?ZxwZ7gJ3J*{vD(J^n zvH07Tc{ZWiIY)TOhFN-RBn4_XTT@Jolc{z~nY6GK0xnVaMS zeGy~BJTb=I$|F~&pX=rz+`5lRw6By|Y&y_K9?U z{7aP*^%mG|t$eBMFQFB@9a1nbr2A#8TSS`KX8yNNO@E+wsk&6t9Q^~_ZMfgEex4CF z8Mcw;H4=BIE0k+kdnnMLjJ>e0!HyygeczQT$mX8_3gv>$1{MCE)T5f!BV;T?{^3eh z>KUl#9KFvU^@>08HL@ez{Jiwy@4-I9XP7KiwvF%QN4jJkI>pY>9A#!$aP=*ee1lKd zhQF_oKSHHmgnhD$aELrdGRX*V@^d@D=k6MzouTdHE>%u6o1^aGJ-{Mc?hqhZ6X+;W z;qFT{5^sudAY1-(Pw(&J7;TFn$gNfQ31W!7TGl+_9-3-?k^$k$Hu?@Gz~c=hL%&IU zmIeSQQJJMVM-FrPvdZ820F~m%UXO8|Y{xUShRrlM!Y@%5W?QLX6&~UR01tJ&gK6dW z^PeTm*7`fTS`paGAeU(Z%c`9(>~)CiCfSz->2{nwhIUv0!qeB^8W{lOYFXjxZw*(d z@K=^uqfD4vh8YLQEBI;V)GMumH>i49g>sUuD&=gQGqgmj``Eoa>NyFP>g8($dFs=D zn}hXn1-Xa0+a;kMRek?juU8W49%nSi`Mt7K0SqMA0q!!v@&t8(jB@>#ghWTJRJ?hc zn0(PK@F^zV=`cf%ihqb{0_jqL4D+x`d5d6*%M2M`-vTAvM}_nibei5@>$UpdIp7u~ z`uqJQ!JKQBYN1s`ts+nL2nY7b$xpUaqE4@XU zL0_w)HXvaSA}{SWv8D3@>ZnQ%adqEHIH|WvjzQj zhv4V#=Y#uK4w9{*sid-lb5@^779z%GC68g1jX>u2IA~ zNHf0&4Y4@KT%o;!NH9|^BUovc1%ODE-oStDaPdFD{O@Bxu&G*7t-MMOd)de75xIpG zYt7R4wR?ceG0G+rk?-^Gz^CD5L$t(%!;CRNq@HCIEfShifLEYKZ${{c!n7wN(- zDo1CKqn|5FPdgXmNH?2!eGezufC*gkRL$2Jp^0yQ-_#% zJCgO>eO9qve)5&eWS;&mA%9?WCq+8q4~HTVaPVGeQK+}=)?sEXwf zAM51Ehg(IBJ>AQcL(9+&?n@^D~gFt&(X}F44)9;x8%JR4D_!{g$lxrki{Y zKhCyK#5K_%;}AH&jkm(v_6)cBMLv&fxcWQm9NonGTZo-ZEAc^{H0lM#8q2`jomx?b z5y7HuvQk~WfPN}8NT56E!t+D5bhVtP|9|E`LQ2-rPM{wZ>Zz91iPY$-=U^?iaE*#G zEks(AY*(q}st(g~^gD-8%?Wj={GKBv+PQ^4f~!@EH1+Y>CHO6R2Sc+s#io%GZ58YR z@#q|daXi8-*~;I1jGn1sk%+x}4Qm^TarmG4KQdWI^>UlOXKM2fXcfM^)AlsVm1rP5 zw(=CLMOj_V4D!x@($i6A>%}eO6Cu%ScEI$ zb@C!c{xMkmH71LvYZ;{&8bZVNwK!y;yeet-+@=s)#A%T_@?X_k_%9izO03U-{rS4%EZ zM!ODkh&Mk%W1YRf{4|1mUmyj0e}v*4)yjB-AlPz<0DfX_Unu2e!zjsYw@RD1(9_?sHU$SJs(=Y-DOYf`UCt)O=mFGtHw*RIcGG!(4PhcN9N2Zx=6Ow2Ef6mwZ0P5tokNn^0mk7$GG_%g3DaMiZ{+?e} zBkliW4m`d1+s&ftg@Nv!{2l=&$=C3SX79ikNc~)O-!0>kOucSz5}) zJbkNl$P1gOGzzd>VNI5p57l|yaG+5hxiBS^2{tEsAj5E$JiML<86>H zPf)Lcm&n}Rm1;I=$LLxl!Jmd$x`f4l`}@;QCmUreB{Gyi}ZsR!m zd8_a-j&5O?JK@%MfOcY){4kqyluUJwW}(hI)Egw?saY7_sd21ArRb1$0Z&(zu$R9| z$ua6US2qvYv|PEb_y6R#3+iU?V>`yUMcV&FJkK(eC?BT31s@ZymBm`19}V)wKV18g zZc!}RF7mzBI#HpBWp3>urQ}T=Yp1TfBHh4!nO!9^#A7^C|4j^dk40Q;^_r_#oJjXW0*8a&elyc3~`~I zxrY{M@$sQp+{K+_7~t&WM?coi-NWJQ(kRH%a}M?OW*!3qAU(y{?GrHcW9{^@4N7dI zK0uArMZ5e`KSLg;C7d_TPd9{G^7s4~n_)7=xKN#Wl64g0 z*d*1%Gh0hN2LTdgSE{*Ol6kgD);^+Hlxp=1(bh+-_8bcW#5jF~dzfREE?;>E8}4?T z8D%S9op?{G;~g0G`Tz~%G*`(qwN>i3@GZPiGVBBL^#P(|AaCa}zD!ZLYqMm$G3><+ zq!qBc-g({>XEh#n%lnTt?ZjHhP+NWu^$XMn)yrZqus>sKXPx6#VE*izFl}%Fu z|H>a(JCCqT(&vd`fTCSQewWG=DP!+p?`stNzxRHPq)7bp z(9h}VPrU9Da)MT&fO)t{c!8u^tdnyO{SFNI$T0~1tdoOrR4sdnC{we8XOvni#nqo^ zb`K2%fWEVcXB#G49^l-^?ht4d*37q$w2h{n0e#K<0rsMmN3@|*l5D~@oToKQ^9ma3 zjC5g;F4%4xZJZM93h~s*&py=8*r7zZfPHWbBi}(XU9QYAuu6S~JjPurN4a1fN;}mn z@U0%=;|`E(MLh5Bqh825;1~=5u9l6nm|#Y{&DSwXDNuWdIRhSKFPAgP66%L|T%u*@ z`m!5r4||Gp20#FLimpp&9q3xB;q8E(ro zVHu2i@@;T{t(Rv5t3rihGSmz8n5Ro{kZjH~aGSt9Os|k=1OO9fT_XeZ&f7IZGe^IR z?-X04YLLV*=o;bY*UvLfhjtZV;psEY&bp|XIL|EFESFcT`6H#5$vpc8L9?hpVTu+A zGR_+A&?$wlWtc9|E!OG;-!BMdJ<+mFOzaoN3GnMS(F+3VA>raQ%L2(3KFcusP>5Ti z9p+i1fOP`u0r^Zn*AbRQ+!I)>Os$kRHFC6_u+r zDX1r*-uecl>Z)ZO+NFnu}ah%WODSB=%C){q!F*H7gd6Ug- zk#vhHmCaG87Iui_*;J_S zpnyH}@D2&wLYC<~fa)f)_Xm5@Y(Id+SWmH=hCn>{_&KK#uk>narTMI<=U2shlH?KNrtSwC}+vWs>R#*-CucnspqO?-2(&N`?=1M z6U~6%i&cGmqU;taE|6qupzr0XcJOSYpTUpOX=aZwLY;q$33N!-KR~IJ$W@gpQ_r2D z9AF8xwFSsjRF^^F${1$tH(97cMLb%e(gT19#SwO~rc-+d ziMgSdXA|{hEy~B7x=?5C_MgW3$?k#1nUtG$zEaIkP{^mLrjAh_URY=R9TUVK?{vEp z)IV}OgED@Y#_{)HZ;Y{?BA7-62ZMp)FKgs)5JLi8!T39n&2vIc(x0y1HX-=yU0m83AD=6P5|uR)$MARymk_70KmQc?*G^)A3wJqBeT~jN2xTK* zNvWH^tx`rOO`y#xp+xNrImjZ^YnK{z?*X`%*E)ESUTA29^Wj~zBf(^dYYQ9uWS?*y zzmu=!n{*BP*c3hKbhydJL4^Xy3(0JeY8Pv?ccF5#q<+RV;w-&j zsbyxhH1Y{w7Xa{OhqG^njKXo)z+1o(ZQz$W?(9^=B_HO59Yk7e0C$k$yzopmtOQM%Y6<^`r#OgUwo zi?aj$zEem!L#`~%!Yjnx^Zf&G-y_x{=M;PeBiz`_4FsrD#5fIcK1Mf8(T#@z?c?Kb zDN&rJMLDHg&e7k%ohOs10zfd$<>*1Yf?e$5vyOyXrJIiPGLI6^2zMD|@O0wr|Hy*+ z*g=_KjkD$&Unk~nUm+B4Nilza0D8&MPqna)m}PDf&(t_Y9{s9RWR#Mv%rxK`D_?ek z!qdG$1OSXPiZ;{C8l;CgfxiCvR}1M%yI`J{^e?$8^kb*6K5pZba>Z#nx@rB4EdsMd zi}+jELiHjw?EM0bEIq6Pk(N?rsoEVpkAUv4fAWAk7|sFiKG0XLezX&f0*?Tqjm#g^ za{(R}@o9#Hn{2~>z77Xi)<0EBN>u*kzj1D}h*64e{^LiuJIJF|GRhIugL^RgE$E%Q zH_+X;4v9*)*qk47RR*aT`>VA0hhFyA$YJg?l(k)6nU!i)1dI0_Q5$iC- z-6*y|{RsNK=>`SivRPt+$}P|>xJ_`D$}?z+p-g^}ig>46w3`~?9{E_l{13lYCepr3 zn0!&aig?V|m0_Cr1Ok+1q)w7?8E)$tQa5vgHOLNUYf!356niGbJHQ+3x{uS$X{O0)P4EY(zC*Z^0)R;@7iF!bXx9?TQkVWt+@T7u&- zyl_jKK!f5seyh+JP1n~y{97!^YKu^2)j$|43w*F5r zAmS~LAfkP$8MdJf5^s-I5u&XX!USv1u`AeUt4HVn58mE=Y#)bOdGo9kOOUHr3xs3p z1&1)Vs0NvI{RNVhFK-`zY?&r~S@j4A zc8zf0=>-B@!Pm$Hxn=21(Km^+jH;BBDN)bKR;L@}YaU_DQJ|_SiO@tq0*8e;4C$kRi-5&FoF^8CsPh+-a2kHRL;=R*73wob4FHBvbeI73w~Y zZNj^R)pFPH5>3~T@O$?N{_gw46AZJ2VOAfVjgtah{tI8N$SDZ)?6`y%Y`=%pC?s7j zQZH5m)jFrhH!$**cTl|DK~9J0Pjm6ML|VMO;$HT1jxmuff&L)ey^G{^Qs(jJh@aj_ zG@KzFVBl`gQE~UW`!+h_YBh&ZUk*(9)T`x7!2yZ*a zEk{*1^9bPspvjajUPwRNXY`$73R^;lU9)wvTUTGC? zV`>+52>5wHpPs|&W&Q(SGZz^G0eXg@j;}}dRaBKS)ZG$6i)=ezm<2~yqOoq}^R%}g zj1o<=t)nRBVqX+0g?cg!aQ9EJm1v-zxqG*Bs{Y8~3= z)z9%$twB0X#XNh0eVs5zooT2>vPotKYKDzssgr#R_aFG^XFdV3Pftj&_dEiUj7O=7 zmN7T5&gW>N-l*jUI^EwZX7JC_uWTc$72@u&jeoR@c}+XtEtsjFtt{29Scq~?HSg&A zD)LE%Mltc?DdtyGi^M8nj*%};G&3TNQKs!&oWnfBy+Tz|`uPVazr}w0RpN8>M^J47 z|A^mTBs54giWg{F#x#kUCZ!tvP+6c*s5n7^x^oDvlY+WKJ#H1w))i_KY@enrR*SU= zb>SAx7D-L8{)oIc5(qnE2+#WTtD zF*HGcj#9Dy_KIxJ&Br(q^QcPp;_`sFo4;FlnB2=R@Lj!vXkC|}rz_^#-EZ$igS2=p z$?O#KHdf;6nn!bFjbbJ-`sutq#LGy~iskUTGL>+b|DXLSXcZr4ySq{=T%$LNOE!F# zzD9J3uKKfF0{Vt#qE_|$qXkN}Z~g4{p95^fTE9P! z`~yGM3hAa*fN~|+@fce_w?*i?h<=W1^d&6(#WEr0W`dcOPhbe%ZVw;vwzG$9gLDI7rxJLy`n?{sTwg$L;0WwP{#xODPz^oi1gVz%D!DYx(t0LVZ$zt4l5)Qc(P5zn9ImMWJkHj2wvZs9eFp&y3X z^LOUx*hGGN!ZA>;DB8?8p8K>}G*7!jfNW`!-a0Bv&m{R6m9Ohx86>fe5*^sfoX20E z^ove2?_sAIkj!1-P>+|(zl^Vw^!|`&@l$aR+d8(DYX$Efif1Im%{I7RNnU0{$IEzxdxPvSt7L--o}gDuW6V=T>n|b;9}5pE50x`Aqy4z#0LPuHlEvHnt`Otm1`k2kYSCQ@CcP^DX@Lb`;r zIm|rB^r-H)h+y$q!8wd*{8h%?OAPg^7A82irKE}~iStgu$% z{?0yRKJdIvH(jnRjU;pDDhB3kKu-6U;UWX-ZX}<^PkTE0h4L9k&LE?Ez%10f(zkz*`<-IGmZY64u)52LJS zXw-_G1G{;dN5>hXtf}UF-O~+OhixJ$7QDPd9jNBii#$HO_twd&lbWT*-;}BO_j*E% zqg!OW9gMXnb?ifmCAdoy9Q3w1$G{;ziw$wlhs2-L{O;&uEnPdlO=J1qX@9GSEk8xm{6So@CPuU4{nw;U)B||EQ;Y6UsOqc|7J81VTDDT2uPfQ)@pqu=c>LWkeTF95D#46kgJR(ZMz9@d#XNm1V@#42 z%h`rJeRgmHTqYRe%~?hW*I#`pQB^OBu-pu%`1?P<8Wf;f`C?fs9%U|7 z_a@3ZG|cVlHpI~|hhIC?`J^0foJ%Ecp{ z%H-I3;;deU7pm!1$d>oA5pKH%qc1_+i1*$hNHz{q1bK$IvJP^$Ez^856lypB9%iE( zqg;(}@yVuMW`Lig`?r{^lVZig!zy%%RH*Gg4&b*XTV;|=Kbc^5jLI}7TU{+HRrfZy zO=z4E)asM0OfZPFm?U!y%u(U&sQf5Z*2}VtQ7=k2Y!OL(K|R~U)6I)~S}8wCFIVOL zzEm0Zx<#am5A`_rDeiWMK)ktnkwk-W3U4xI&?we+-jq8t*VnALOW0P$tE=a*RSVw}I#7+$L(6K{LkRi+p&3j=P=z zEX1}(?u)5MXyGG^FI-)!by({G@0^`qr4z3=N`~2ga29Fw_H^_6y{8{#b_6<+L=)~E3=AceLNWQDv!yP6~L@s(`Q$rY~k zaSyZmqb#mgo?!cLpOXW@hFL;4Z?cJQwnRgQ0p$H}o~CY{6y&``gKEh+BF)U}&m$}! zJib0lF^+qwpBdzYyB%()R{Z9ZNiyxUObz2W`C_zHh+}{Y!$iK;DKhCYS6{Q}9Tfef zWc{Dzj}I^)phBGa2EE^v3O%YYtnlzPN*f~%b(v+kx|^bS75gTfr&qdp9}96yt|MRX z82t`XsB)Mt$+}#=OvTZ+U7Tg&QTZ-5&Y@(@Cf+oaX|hmtv@P3cyJVhLlJRdbkyWk= z?ZlIkKmHqV3~=^k=me z_`6{SnL>`fYee#Wh8g&SUYbuXVebU0e?0Z_dr}tnY?{F#TtDem;?v|qbjhxE-bH-V z&t*?phD2I8{-TSOd_|Z&i2V~^9HAkeFHmUZuM@5jkuRbju8;s#*BDciSesA}PZuAk z65$wd^*2flafG=+KE>HFNw$m`WDjuhbEBC-J1SO7FuR5oZyez8^7{Vx%_rqj5D)&j zf<&T0KP$<+L0Tt^vTub&us6;E`B?sYhlo%&*+hj@s8_sMkpj(9q+_66fd&l92;~@~ z($52uNdk>>>Sd#7<IlD|r9oJt9^=64!wPG1j%DmVezUxCq3VxCv^g3$ShHA_AId)+ zqD5;%92+Ex)N3V6v_u<96r3WMCbY6$y~7=zf5kYW*c7X}yP+GWnGtOJ&&=(=t|U{b zx>X9xfx}B!t)dhYIKgI+9J1uH-B06q=-DDz)+{ zra5~YVwt<>$EP{MUS#SN8>GKnM&aqexk-D@U#p$VJpZ(4gso2YQE`#VSEG4K#-T}; z75p?^c&JZreFgh_G$qE_vE6lB#t;utD^}hqc?=`)#DJ6Z5EiHO%!Io=CIRCx?*NaSBy0 zQuZ(coF^#T1lt5|p&y1HZijqkmN?09fE90eLT-_gqcK6dNXXYV#ju7SX73T;6nJ|l zU#0PLiZuN7tIQqj5=E6-nqHD!jtQ{_it$8!;^S$G9Gwac^RSx7vA?@;y9(&Ix9ESs?NUoqdAerW&;h|61)JShUk@!ynzhX`3bHJeFu5WPF}% zl1)3aM8nlltodEz9MaEalSr{D&X!>AO~~t5iWR-IGf0N%DZ*(+{^4Z9sMlnx^@>d6 zCuk+2(RS$ur--dW?1R68|K%0&>N=TX71l|D$pAxwiN+81@l8C=I*co<6OHd8b-~s! z_Z^~ns)dFx;)`_YhhKaO^F==Vs(*7M+TI};_bOkFc(YjN5<)2t=9arhvCJ_vz~xu) z5zilg=NRG`eEBuWFzW@`=o*>z=W>-+A%D9s)`2buD1mON&)>N~KPM!_vkdgZX3@kK zF_tB&WJ|-Wa#f1u%%fWQmN9<6y(2w5NN4wudD_IQ@Rz8^g&MoqFXIR{dU))@4b#<% z$Cw1$^)r91cKH8a&7zbG-_+!)u#b~1s)R3LN;L;KH450rU4t0<8$`z0CmGH$YQ#_A znZ|@#y9K=8Z{Y1>#aT4Vn1y$7nr0#$x&`reAsq;G_;~)e;3527;jgmvOP){^#p278tVz}cOZWFtFD2`Y@{)|h-)3v_ zx5vJSGp&`OcwpVgzpp{|TFGvq11$9)Ch;X|KjrwwF^?z)&d_T_&!FFZA)6KJgShVy za(=hKI6}`pSo6d@3Uj-Mdm4*%Iqw*kGn z=@2?Yqg?v&-J_oq3@swlw43;u`kDH5Qu$h>%U5u0LjxS*jgdBwzkhzs+a2OqFZthl z?tfj7_kta}I9j<+e$dUZOvW3%h_n2Re9hbULszWQDMT;hNoJgV5AU-iXc&bm*b|q~ zNL$iHAGdJ3bGUEn!z^~c)HmSw)+}|R3H`(<8}6=V$rme|FUYr9I-~4TrOz@DFINfN z-dn{9^$hZ6X=oQiUno@^z;U!@o5Ea$II#?J4j`TW@Ba1>5G86po=!odtZ7Eeq_gy@ z-`zh1I<@o2HCD>Ki7C=7c=AJ0tu)%AQYypz5PpiXK+Db3HGpFEyGp8Ivl#8v4$l8B z{(r47yF+xV=o}qLhzs~$Zs}T)mKKpG-zMozl0AJ4GK^Dv-9?*6nIRz<$Ba{csQgcy z`@b%+#z$ zt@A(ocj)FxH?)h$)h^Ih`}QJ4^O*Dt&~YT`_S`LFCVV%Wa9!Y z!wlK#|511d(&hB$S-Kee?ZVl*C?`03HqmHDoc$bqLM^TV#z|2&Q?$1*@88=**o8HU zcL=0ECtd!Z#sB9u#^~cIS2e`;{c)j2fJ?TnX!8`^Htqt&|NN={{_^l4TUIY>7u>)H z{iGKt)rt`=T>Wcg{(UCL|Ebl@!!{h~tX{-6R4q$4MKiNP^65>ns!1~8`v09@V2r7U zr&RgNt1dpap={kNIILaMusEt}~BP&(cnViQrs)qRj#wXh&;A3=@pwjN{O^G&6i%oc%&=JUwhf+`SkF z90NcXPC0*sb_K^WBHAp{!ZCn)%+)8}DB3L1AX(oi{`#{*1y4_@au=VgzhQc;1>)PdRj9iV$7c2#$@`HKw9+I~^!=PR88rCY>-&ML+qG5(6Q-6tUoN=Bq_-%y( z=Dt-l<9LGE%eX2z#}JJ|-E52lo}PH~5>@D1qtt3ytUc-435I^o7)zs6+X$sH$$F+S z?p~T1feyAI@kZ8RzAly#*lXBp>RH}yu_o?bkrt5_>|NSvu0GV`9UPJs>RGfS!u2Dx zO?=QXRIEv)g=K_niMtm}{6s%wA7mfI-@HHobHkx;VXxT-S%(SNG55(A873~_Z=o3` zkWX)+x%zOoiC5VN1>40NCF{u-vG!!E3pC-bPLbCMAt1K!V6SIrK6pI-o?;wjT`PTv zZk7N##!J^0tM%~6)MV=(p~aieQ2|}~2M@;ae$G>5^us`Bry#kiH6mAkgN%O8P=^#_ z#PjEAA3QJ)o_$Rz=L4 zFhR7|!()&^HFpim*)P(PV*K&l0>zVWI6E>mC902py8G&7)k?!(wh2XBLEdW?46(He zgDMxJ)IP3I2l2*mJBEoYJ(`(Le!Z-IPNXwXVbdtSg@<&;*Ckzh10&FpZdfA|Vf*=Y zv#3L;rw`JZV!3ED{N)Tyq|F*ph+~Or4-c4%Qlw5bH^$`YV;5ek4Ek}GD}tW7Cn!Q~ zK~B`OOk>ruKU54dp5{hd-9csRvW)CunIywqHHte0tr5NYvX2RK6a2PWly>@qhsOt| zv0m;X^=Vqz>l&Fms3x&@ADPB1W5VoZY7)%mDWz-KhCp==#=$xvnEy_&LA=V}$uO}< zeh&%k0&h3*>IDMM4*n)@H`Uw@&LuqTHR~{cC*2hIzS)LIR*=qEhdKM5vzF#8)A+lVbZ zj}LJ#i`2P$(GN8WA@A>?c5t5Nb_f`z11&?i-3Uv$VwIe)yHwpOu}!4P58+nCbMUmp zs}%}wgDqm0NuPZUw-ar43gYQ$6lWR>a7lboBeRDE&fYPkToH30@m!-&vw**Iimply z_WB47XD8WYA2Zd6X^g8c!Zz8&G%3kctSL)xkUi}=%#Cr1ZA64EsGoWE^#J1-^$w~| z3TKCDtXQpF(JtH|1K7TAPma*seXC_%0zl;r?sk-QyP#FHn;-P8r%!+j%?#2R?DZN^ zoz%}q@|Cs`bWgzMpB+UKiX#*U5 zT_`6cDPg2hFb~BFib>VK)%z*wjLTzGAti$w^JUx>2lJyb| zVojpW;9lVFrJRSmqMGCCV;zkYY|u`-`GH$XC#OQe%WIN8+R7w3*+jf?olvUI`#s)P+VfjzogC{Za4V)5AEK`l zYUQtxi5S!o-vl7 z$Fp$j9Mw3ZpBwS2OpS30Ul&ggu)_i!cTfiy>x38wg6#qwbW{ADz`FBy!d)Sq0RqEmST7RWgsh|bIq#rKRGY*S%x)m}P=@}-hqY5-t=&@^Ty%Lab?)TA@NOi?g3@inE`m zhj5*A8GPTE`@G!@6P*3v%jWGSTn9J!5gNF=#G3x|?Eh$y+Ml)SuVsXutsNr9= zbughuscZ*_Xl)-e)M0~QjA@B%g5hoOIbygS=!gC?4s-wa@iX(iTL6OMuR-XknZ zWrOU%8V0zy`lB8{V~3Ie7)ohO@XFKvP_M~hv#W0D9WX} z*>G3JDZs7!;K>Sf__{NVu@C++eu5cK&#Nz>zY@|}jf`PB(b~^PQ*?{u z^OT-GUS2#sv($^^bW>tYoc)6Bg&L+w>4up5yVyRS&(kmtU~brk7{|H#@HZJI__}DP zVQv^Ez(2W!$JycUyoba%AX?)X_&v-2&s(&auS>KUs6|1HNH=u}4|fG3NWpgg&TCl2 zbL?H`={d_wskL4KYkGk4D;n-XU*;+XW%-#~Hc$_A&P{4bw?h6w8a% z@Hd@;h*z&+gz81p8_s59mE&<-}2{vBFY3GSGz50T1F+(%V8g3`jlCA6gzC%Di zQ>iS)c!Pju=Fjm{jMEKW{rk9>#@2|uycQ^CXmGdReER$v`t}q##L*;Ktmy=0fdcqO z0WMv9zzbxU;OTJ+BG|}%$}ymwH_Qrb?>OTqvueom?+D3CAGPnssRh}cBZPiE>DsXzOQyDeECV_B}4WU5*MPIZ6- zbH7^FEa3ZE6 z+UW}fo*v41F!POLfO;0?1pN^G5PO%WN2rZrfnWo~iEKkCCx9iu-y~W2@~W5n8kTxi zs%{hiX>N|r6rE*Ely##x`QjEH$3V1IhQZ4?htR*qH%mZ2?B-n}3vj7W`1lU;zC_jg z{p-)^hTOf;R!_dU1Vq>lvIDCV;%F23_1W_@s;r_7GQ#b|nqGfa zDg)gwsOAbZ&Eq|Nn8xa)f}F$~spd2b6e>W!Jo(B|X4+|?wiz1XR`kOWmVQpl7%)YN zeee)HUkm02JsoG(bwJG*WI^^&m{Rp;@e>F_nrJN5-;I5@y+8s zKIms2qoy0eU!t5;%1hT?!_rRUZ-QQ8I6Ij8a95!3NV9un3&}?1R|52p1?Pz%N8U3M!4wbG)|GJd6B48hPf|WE!1Wm_2}ncqxW*J5k*^p`{mn{P={Av zAn&PXSBYKylTG5yyZD6Km`As9mq~}%=%!GQ>m{9nT>UrkO_M&qcJrg0cL{j?+1>XB zMk^n%NY}9UkZ*&fYwIN)L!zt^&jXzSQ}S2)9C4QVWn7NV8c{Dd;rcAKK!;Qv<9LiE z#ezr+`ykU;g@T*kyN{gxZhmY-y4lmT+Ic)Z+Id%S(N^|h0WR8kGc?GjcTlv`cTlED zes1)WUS40Hfxe0#-^E%y1V2j;`Bbu=Z79Rw5FLNBkL!6Fu%9(D4xu?ZgzM>skAAKb z2EPTrX1ib?*91ekVv6w!$sH8yF!>_X9nu-#$5=*KMwrGx=E6XR5h-NUXhw;p=5FjcMikcy{tnFjzycs#&Xa`mYf zfx5;QiT-y=_$i{zer{+-!EcYyF!zm8L6jfjs98WWV;iBJcZM|1s96B+vKY&U;cMli z9f3|8%cQ5spd%xg=l1m(>T$O2E_ULJb420RamEaTeasIYMd~m&0WSTVfYo3h#MvQU z-NmMyXB`HWUpzg4rxk1mCkQMyPtU`?e#v@~7Rq_Xar8svQxFrvU(Qlr!{Tm}FLDgv zZWFG9sET?P_yVNMl=FC7{GGr;bn<5yMA&8+#8^gK@peOJGDArM0`JiT*b=W*U!uHdfJ}#lQ35IQ4r81Eg{LLPodP)1RDY_FB zni>5}*y~Ap{LPnfA&v$aLu}%WFgL)D?&tLJoTcvJNif^NnWl}gBw3NIei*)3(+myM z*gR#GoLzYE+Xe~!OpJpGhENB+ERDht$6oHl7q0&HVU+X2t+?BkF)%lc;!9*=O@3}I zB6U)T=YDPk8y5&20w&2A2jEM|F!=e1zw;EiMFjBF580y4KAwGC?>^2_FOl_gMqBxK zg1eokC)T1=**>gT?T3nbkzsn=OM{Gg${wC5>jjDf4B=MRVTuLBbAkVV^D~dqPjU=^_lK_wu=Y4RcTg-N5)I6w2p3dy%cS&^?1S9B z2p80|2p8ywfNvgQ5$HHX7j8X9#oOA&K1YPR5^k-LnWK`gbPlYM`QQ=g4A_Z>?G$6` z*)BfA^fIN#-$9Rp5J#o5r@13631)WT`C2kHa#hf`nfhA!83x$9po1aHNU1W#!Ys8+ z&ExM=bnAp>3FnBe{>5tX=IMs3#N547buc%dU$2ln6rXsthsQL@)!#N^f?=N0BpG~d z?1Q4sL~CE4ouGhBzc-oV^|I|<|90Hab~rUr9gFKd~Ueb71Z0wG)1I!d{; zQXWi90?{JVSgO%O@s&&Es(5-p75A$zH!y0&A}!yZWa;&CH;KLe+{Gtb%{*!!W|B-f zkF_UXX%!t|`yvterF>na%QQ0>2iLGDCl?5GQ;g%+u%yel+uXh6i^QwIFL@}wWW7)u z=`!*u$qMc^CrOLb5_?z}& z-tP-E{atM%W~oz+An(oN(+wdYB5eYlACBKB9%Vhqo@~P18*Z0j0A`G;{8%B`AShPr z>Rv z7+@0m!|_F%8zc%fEMxMt(+y3NE)Y;o;>}HyKnH!(qC1Qr&%dyALqf2e05R z5Cl3(RG)l%_t7Hu8rC6no|1SK3H-f(jrt*Qvj=Pt77Jrj+9`+jefp}X& zZS<4C5|b{Y9SOFJH9ge&gAMq?Ej$qGWA1}Yk64pnJ7B#5lLYKP$SPp%aSQ;ynP!G% z1Z0-lgo59Ge1~%4>yC8R!^7Px-bl72(xO>l8H2lBs%#NE!SHZ==-Wkd>nORZ8yMPY z%>7M#FwG9w{J57OXLN#+t!tbDzNY{ek`>V9oPAKHCe`TK*IDXI__XKV?{#vj8YX4!q7}lYA}w&33^WnFfhf z;(ku?Mu`TB1~%9N_C2$FJbH`q+k$&VVodE2j@U0D=wR)q(hK4~wr$po4NA=?r&UsEuQQXbtJ? z91-!Hev+$?Y>9dn^pZtAzJUp|uTbdZm#ZRO_VgiLE>UF~i?r$CIlxdVlc|BaQz~-~ zd^r9b6}a^POWz?7oZ_8d%2m$Gzz);s^t2(1l#4R zvUT+{L6+>HTfUNNj;DuVf~zm_MUi@fnM;6R`?n{#Pm@e-B3~pPp-D8HB7@viyI`X@ zU)L>kvnYQj$_e25&<|NgsAm}_NLGl}?x09kKu-kHWz79wJAbhz#_<)B2?pj-{!TDe zl4=g*f^oNbyMqfCopE+}yU`De)ebQ5w!-Xh zp#d{*6DiRUZ6#m%aD0$81U-gpr5nY;onsRT>h3EPu=ZMoL6!0~ELR^m_aQdaW4^9J z4Ura*38h%r#YQ?aO6BRXh=scA;`{pS9Pve>MJ&n6AiK&BxvGcbgL9Ry1pU;atYvEE zDJ^0<`R&6v2C#Q~c*L3zE|$nL^|kZ1aTz8AI#P|4OQCP&sw5gf-Xz{!v7Bm7s;-B} zHlj#9#Bq{7$rNZBnni!*{*AWE(GhRd%0Iw(^`$`~OYglm1O&o`R(`eY7T%XvDbs(XZbrxRwx$G4?zwQFa$tBz}<^_41f9akx}XbMY$sF z^fBrwGPuQwSIrXUDJAQNS#frL1wY(wkvw0^`+dB*$A{0a7bvpyKt_sUVTHst;tYwu zb06~*nYSBjuTH9m$0fkm{qt*s3@wKRh9WKCtz07tvu_tfKm7g}_~gHW z4<=}7768Bd)t3Y_)ML(mmJv@M&VDfIc!A;w4dY;ton@p#fnX!tF52pOnyddERJ~-5 zPL^JlonLj>i7Vqp&} z?fEIPP#fkx?X*+SFspgIeOR70;`u5um?67P_$&C8@_k$&e<0cnc^_+0tOnu=$omnN zM?YVExrJsMS|XdH0t%5gpLn~sae+cYz39uUIw`|+=23};S6}k9vvorqB5VoQKfWVe zPkh1IkH7gV_+a`;sdA(E9FlRvjIZF*o&&B` zFAHQdxO>CwLDxd?_AyL=Uk9A+1p@Xi``{?EWek`}qMHrs+s7G?&<5F`ePtfS+6!{> zbA!Ge;7~3F`TSqO&(F4zFvW?ipFH!yWgls8a3sl5|jI*1cOwGHGpg+Dz@+3Xt zxm;C_&Jr1Cf1^13Wt|k(-m|YGEDIE@!<6%DMCS2Eslu%<5?{uBd-5yzUtVSEuM(Fk zZx9%#h&6>e6l#2Xl3_4OZxxMt4129vK(XNF2UJkVr(Jv@j(b=tKLG0w&WEoHaP@p$ zfU5-x9nAezV!W+C&-(r6mT2G@fPi2b0Uo|^EAyy82gd-_9Mv4iFbcKC0cv*lXZB61z1I3l`jyQ#9aLs$qO}Fg*yc3reLo@FUp7S*PoA3K?f@8*&()B z>Q`UhebmXJo-I{&2!*;6YQx$q&}1DxMK08E2qj-cIf1;VoTs0Zs#7b*-qk1!bqH{A z2#v8^C5C|T^0J9UJ=V$bejjE1EBLk%83sK(nEUL5kv30rNmho~4$%?M;jRLmfnQ~m znq=B2&eOxyXB$B?!#=1`;S_X%u!FNs=<3flWFNLns#L}@!rAZTCEmC}aDjlg1+*)_ zf?uJamxa51hD1H9lasBBaN!uDpIIx-GO|dXsUK}cxSpr|BC$>i%ylH#sF4wA3AaN# zLOa6R0~uMsQ4_84^e~N~9r5&V_EXLSCCIP&f8d)3`69+~pd%7$!#Kd&0~uuERe=u1 zaiAvyZ^#7#*%J0H^C;pu#ezj_Kj$><6x|%v0Y>7BLXDSku-ERsfH7g1kgKYa`?dS` z7}dwqF(mk{OF*@3m7GWm$TjU_Lf!T7_`Bk7PB2);)JuXqjGtS%;-jD0x|sV!SsXToII{G>BH`xdEGmTQ=FQct~CHj`E=NPzwNqnJEXqc{4hQA5AWTYBJ zSxYq3NonPeGfptf(C~K#zrBHJ6Dm_8TUsTy51XVG?ZsAs`93OHVo7V$>F{sV>{bN>tp`INUC`INu&SMY%c02II=iv*Ou)UzP# zPqAy8C32zJJ4L1nPjJUF<2k4uMpoN&0KpNE?KUOZYpe2wUqY;npm@a6794l{AsR_C&>!P`@vs=zWodnK&dL;DBMat3x5fi05Hq#S59A| zLAaH2p1%`V4&eV0Y+&tS9Gsv~EU*p1+yE;;H^tY*+s!m~4a+e=GlPD3g2LT)HMwe#?{!tH*j1h{y= zuTW4gg1*(sF-hLSgS%2I2Dx&zVxAtE8r2e@fv}DuS-FEsHu?C@CUTiH-W)K0p$=tA zfRzMi_;CDiJEpPs-m=wGbbVZWU7kLXHrTrx1o7q|F32!&2yGGzaip62_M}!C+|A{R z%B6rGZIDMms`127OY8*q*1k1dOm4WJuM0efE&t?>{!<_#(Mcs!KqE*{d%Z z1_l{+;ryNNy{YDUxo4@B%B-V^*7&+G_dmV^w@{RIh$G<;P>9h?r< z9?;K$`}-bJsO{l50I>c-Z9wBsH3tDfH$}1nv`omSNM~RYJlqxRHQf~G9}BVzKxa%n zdx+k{^XR8W;d}3D*=gDvm`EGAt9Ks>HX6k}J~T+k)J)Meii6(658HfQi09uQ12mnS z41)=VEIq*Di?qD?bPcC8w`dlCwrwnl5Z#oB7$vqTbsjO9a6Q6LsRJlmqJf3A_fdcC89uoMPcHt^N zkWUXVfCBIHYm^hf5J!YZ6nMQ2H9t+-F-O*+j$T2{+#M6VjeG3hy2Vn04B^mh6 z0rNvYxr+^Z4RgaW0C)oQL%^)=Vl#~yrv$$R(IS`)W|nXV1$;Ee5TK2at>*3i`AE3+ zq4-yDLu}KuSbOfiby8?Yti!$BdRay4L~9BaMd})bHj!ZJP@R;=hZ-5@z+@AnRD%q& z1eG6}1)&b(j97a%k;tb%RGxfe9^Jybga?*6TNmV_NLC(-pK8>{95fjtc1_AjZ1O(O|!UgcZ`MQ9%?_v02 zO%w}fNT5D|v!A;cD7>(DCm4`VnMZ-H2v{e$t9wY65pcKCPZFS9m@hLKY zXOgL>&n0|;X00^+3WsGtu;d+T`zLs$c@G1*6pzgf9wDW+%5`PoaJO#KQpGq`*^saATLHpuA$0rhy5*en5mbCfy4R|>P3%!GL60ZB3Uok&NA|_?~ipjUn|w<9um}ysTZl1fJvBuy#-lU zP!k1vtybJ10V+w#l#0|J{ahw}kys@+%*rwnZU_E8;dbeUfExfAk8E9_7DzSP#{_(0 zh~q=>!K|KCqgv@+?s)S_`ch@ND*4I zm$M(lmY^~QFyBaLfL9V~!`osV26qmq5F=V!B8z*uOnL|9=>xp|Cb2al=24!W7|T?n zSc`N+@Anx7?>++F?yt~gt5J_Xy#dw*sJE|Skx#+=I)7Kjaip^-Yo;;5c8Y~HB9Lo8 zM913O!NJ^j^#`@1pvnZ~C{9qG<_>W5@PL?vcA9MnWTv`#lS~EM_ps_Eb+i8pU#!VC zVvGrB3-_>$Q{4O#%%*80Y|9mKc6zy~<{tfwwyKkwruFm@Z!FM6JZBw-yNWkYF{Yhv z6Ow2sQ3b41xE=Xol^kGTzPut?G05QQ8DX)CHci^YS|tYk)Bk+SRb}d%BqN;xRSSrj z%9Nt5kj^CQ5zon%yu3gxhK7b@JOp@^$5F-9pP&17Dwc^bTqZZxbK> zl4XQ)9(?_5LqMB<2X%^!w*^$34>^5MHzwGQc65w-fk3hXD#1{XX{UL+H}MzA@i%#T zxO-VfNSA4+fzKz}%rF6UcLz03X&?6JC-BiKJ}3Lpoy{diFK$<+mr? zy(OxY^MCaQggB->=NK?f!8lkW%G9rx^>@wGr=F#roug7Oat!hD)GSc>VHG_^M>Y5E zBlfOMWG^?xLXAwGHpvRx5U4RJQ59<{)Bv`EyZ6!02-{ZSc0t5*syR@5fONJ_$k+Af z_(*5+l?)T0N*Vf=bvVMdMMSXu1ck3luziHZI?By2(gt`m`k6X8d|f#@)v}oTr^w@s z7O^8NXh*Zu{hTSrVfH1eD1X~`7St*lOl$|8 zneny~%s{nofJ=m}Y_&$AQdxitXFt?kfD6jWJSC_!n5M0Q3*DHE(2$0 z2WJZpus3T&Aon*zL%Pi0$=5|Q1L}AF+5!l40Okl}dqJHT+YszEe%{oDuQ- z&*BiE)=V-5f99J{mGW^f*@mF*#u-(99AE@FiL?x{wF?$#2ET>6tCa?s*e0=h$y;cr zpiF($5~w@SS=%ABO(?*nho?b8v0Sj-GznEs7?A;aJlnP!H1 z7WnzVHwXDBke3DL4yrytMiA6Q13vh#>VE+516Y6dL6AWNbtL?qOk>ouI6EgOjN?F8 zKr?fO#M!@#eTWX!z#uLMeih73Cx5f(yN@mbO=3Ww+Rq78dL^o>#N~?4fpAw+brTF| zM^|vD$FBZ=gl?QdxSnd{?@F}BJX)yHB*xXJn+B_w@8kOXnrKb5nX~`#cMxT( z{Ls#uq(4V=3<-6>+lqTB-UyUwEh2z{z};>U$uO9ww21_4)I253O@bMih-4RT9*?{I zM|M!WF;Cm$1Msep&jMun8#xr zWb4XT8l`%9kuC9c@proW4scM;gNkmzzUyZ?2X+W}d?-@yMqUtL3WX zs(>C^y4EZKRAaG>z+G_+@O4qoLO|f`fSwM5?cf#`YzG-Ykj>}s1eGhlfAYZZ13E9x ze%4{JCgE1>U7$IbrUm*Bzy{GzvW#Hwf@(6dCEjkpDscCroX}1qow1Ba);k5&O9JIG z?6qo%T-Eo-;F~-_QTYMtkvaQGR!El-&%s$8p*>GSIr+~>K%zmm8cZA7#}sOVy*@+_ zb$A(oNeIJJ;YY`_5CE)GyKuWg8E zi%wclv??A*4)?rKqEvE`>e0TN{Lh9n!<}`pCH@Wjo2$omd9Rd{QD}--=jfWy#QB)v zO0|x4GxzJ7zz6&76zs*L0Y0ZY@LINe5N8jo&`Z1#%62$-xHL4=p+?#EUDE5z9VW%d z4j|L+5KP88GAqm9KJJ6?6+^~4z?Nckmcia{y5Ku42ER~Ho&1aKp6kuIaTxoJtI_F^ zWek(U+ZxRgVHkA;Z+_z8p*bqLVeP&{W|_Q9%spoQl>S4OSU$v(W@w&W6J4o1wMvKB z95+be=ND;=Pl*zjWxB)=POx^kI`+udDZbE*p&sub7bL#FQ?%4Q%2|nBmFNdP=oQXu zg7X8aSM~uM-GFJNbHeNnRdPQis4vs8;B}k`kkyA)PlXtl!kXoGtqLU9c4fg7H!T@v`Cwf2~eXy-y=z8s)e; zP&VV1=W0&YDF@yFe1EhgtY$~%UJb&ht}TH-c#+9%(8GGo>^fw7(3chm1dzf z@gv;B_jKP5XGz+sn9|IlRWo!4bOP}i}>22R3zKIYZ!<3<}_P}kic+Ryn>MZ8P8Bx@B08M zrX8MJPGPlXiDrxbo*K0}14ydB6Lc2Dq}3kAIX06{nq-(ph$inV)^Mj=y5u5Et5@H2 zGy}-8=xc;DPl}PhOS?>kR@Uh-)?W>_q;4Qr`NBT@1F9zaAr1M3Sf*6!?xzvHF+qHl zC?gb&RQvdh4x=8U+V~0)(do%c*P|k+>r8%7n+dZz`kg~E;}a@U67{&cmL#DU#&Mp0 z-RlL^J?Lu;;t);Wv{}RyNxQZ}j9)O`<>rX{eLsq29joMba&P#$0bkJN?nQ z<>@}#l0@K7sY}lz-M_{pigr+)xnv9+Jg28h6xroxZnoQtbE6H} z0N3$bdMEcfI5@L9k%H!s=1?2txmgPBZVY|Rwo_o|Z=saG@J&(8VKWO|&H{5;4%hd~ z&%DXMCQ<)Jnk3U%&~gcHx1FLm?Yd*y)j6gG`bb1O<9zjP*)rCzf`FtDWDA6WFL=fS(d!Ae-)uTCOFF!i&XP(id8nLfL`4^ zi)8n$K=a3XLD2jZsRq3|ecSt}RgHk5@EKQYKvbMAM>9cGWQ;2t;f3@j-fl2XhU#Q> zRSiY7snVLqI=FKD6KECOt{r~7OWVbsQNZp2`wg{3_qLSmAYnJB0+L8>5LoCN z!ZSvvpi+T>2G&S6iSr^Dp*D%y>DFuG(Tmz+nV8gFOL5AadrbAtor6!Oy77YFnl85L z?_mFhaSNmHd|aE`GNqzAF{+kAO_LnLU4%oM(hkuC6U}U_AHz(M4&CZ>cjexFwvoA| z#4(E7>BhKuc)fRdap>vsGQDR1&NIdoidAWaU5b=_X`Q+PwFhZ|F2f<-%|8a$6{lZw z-#U)28&IP=HGgzooF3D(G~v~5ci`GH&0i~-mN=OrHRnsGp-Ebl1B`HmKPI<~Qs4z6 zR58c;@zx>C%dCrKl`G8tkV>YR&{rGB2!yoYaZg8n1_L#+pZ2OmsVSTi2k0uqufJ{_ zWapJ;*do?KOL6(QUQgZpJu(e54SOK%G#btt1G(2z>J?(JifS)DHNx_TgiiDe2v~n= z<%w_~Q#OE3TI8$e*bcGeT(kG9ktCR<>`^GaK?gfL2H4~-!t7lI_(+v_3?R1kDoGRR zZrnjMiw8SAn22Qz;q=)e*@Cc}9=LW$pJhCHJzb}VQcmw!p?r3<$2vyeJaKo7A)B}Q z`NZH@w)%lO8_cwsj1X3%bg$Z_EtBCGJ-?I*dDhD$o-Wb4PW!Ai0LV1};NB$!dpKTp z^(m^2(&m}HIUT-V%Rf3p4HD9S&5r#X+2004wQ_v{63yuJqD>E;paqgl7#^B*5xIl>+ST*lNY&_=XbfNet#Cx0?`(W;ft z3J;c85}j@_PHcIDlxAcXuu8(&x!%Af7V7hFE@y zrDTj{NdYmGXFo)>GR-Poal{M$lJp2B{20^j&>AnYFuf(REM@QdFM`0UVLEultQO5W z_4`fi2JO)$3D0W|9=2&_(Z=KTt&Tq~un z3Eskt-|qtXghE2+Tw#t1ttuqwP$AmX&eNf_+1g$@ty`w~)ok zIksmw_IVs1NERau#0JC$^{=#4!Xnj^q?`P^qhTC!vxTrtIlbBKo}bG3>piIpYzfXw zET=DZ@cQj!GMfO)!rRHs5WsBHWmVB%2KqT;dyK z<4g!g;uW{)oiIl^J$Rq3GsQR?5;e%{;&~@o#sQx?d7od@4MV_K>g*3blJL!|@1MMM z7dUjwr+W+Z07#!1YosKX-uftU#Iw1-OC-T}4#BDijjjyah6HO5S4;ZYmspk9=MyXH z;bH0r^hB9mf05-KTYrSs@klQs_2d01P?qKR;Cl6QlIE-wYY+5|afon<0rDdd2-*R# zDrwe~N+g&qE|#J8UTu+fz5~p8wsv<}M-+n{HlzX{m?b7rH#5sLhgYZ#l!_`6y)XD) zLB|?&ek6HTh=-!cDyK+;ZjNGZQ{r`!G2mirplNA zoRD)Ob5jC-;{q()n@_`R9Y>Lhv`Hdm0EIabyTB+y^zSKyQVX-n7GG7Wod=ufy41?l zUqGUtOlK&%fX5R63fgYSrXHoIpB^RdH0pe@OuH_9^`@9(#On{v$QOIhj5FP8g^hFa z1}AU+y`;7GicHF1Kw0@h!XU8DFc*JHNw@`X z@UHv&AC}bDSRQ~bJpp~==yVTzXu}VD!X8_7nybC*lihRgX3p8iDce*cFIY;#Q8oG5 zNOqrX7y@N-0>yAE@a>qKM z_u>ncCjL#FSA&+_k_Uwi+gwyDd_xU-5^ZOwKp4~PL*=51_?bDveX?~l*Q!K??tgTa zSs=909{%zzh8rlB4b&QdA>0Kvu}YN339{CiY_~Ff;1U4RcT(L?kLxq5kmV-D48WUY ztI&@tyt;tqr~Mq98=)kGYsl{08aX>5)>@(4$B}BvFnw|${E#d9MM(hJHl_4cWoi2+ z!P@;wYf!374kd5+CACPK#2R(57R$_#P>n{4ojJ~j`keD(8%765MSP(8f*xZE5@h4*UxnNB&% z!fDRRB^ah%RqXd6lx-)hV_Ffe33%@L6E~t@+fSg7C7gF_rCy%ik0QvBzv<_?)#&@F z?=`yJ_Kl$%?Div}U^9i)gju?zY;yV}-9BTdHYNIkKQEAI!h3QWRagBtAZ}V9dEY(u zOT;>)-Jq#Y?%3sbSbsS{=@1g0pjU5N;xravpR*0b*l-UQ1IGp=H3?0Of|w%uN(hi| zULbkj3qFqWgbKfZ5_RkIgu2ZGem+ol7|Wz851mBs?q=g-iRW#tuQXIeHcW$n@aTd~ z>f{{$FDUFVQMNlck8b(8b?O%A11rnpEE-Tb`N_nTSWTzXa%rseA8-%kBSwzN#c*|x@pz??jHFj+=mM`?Vn zx!cis2%%cT5DmaJglkFK#%T}YkzrGEYGgaCrCN?Kd z_${6i!3e>qKyGe%iN!7&$V#a8hja%gpCO^x^L2fR7UR}A0qx!+on-2iP;(IAd4@)^ zyviUX2r`~1A)xTQM9cWJ(`85~FgU)HX>NhvEdodRaAYlBvAsQOpgAbHHmC{F!4rJx zqf%v@_2%eh(FDFBmMQcA_9)#R+!susz3>Z*o^@(XJ&dJY!z>PM0S>Whz7Fywxo9?-9)xW;K zm*QF+u)%%x`JFVyGFIQlGlq-#+>=qr=PdZERqS=qyR1t)7_UD_$BSPWWaA6@Vjd?y zHP-TOZlu@QDO<#=WNSPJ$(Dh3Y|s%vjzIR5ARC3SPzgenIKnd zK%zUba&zzI^)LcCz!v!;Q6I_a(tWY}8>CawnhJgTZ?js4PJ^{aHKZw=PTl88UPoO+;932W0oT9u|hb@sa!k6hH3F{LQJDw z+|_mZ#)+-2E`=6Zr|0nlzY|A_6d_@DA)K=;2ffnvZGPtO8^MFoq}^#Dc5#e;72Hj$8-aR?DPqH@JN=&XA&4A3-sH0 zx&i!noSyw_u*QhXW|zpYhmoe4y(9yumd989J~J#)89>>gF1>88W@Yazl*h7H`)5B8 zXu&oM)JM3i?A}^JchA6zGZa&N_~MzBDb~e7+P>)>j%s89UaVuqss!VGyiGde1W?xi zw}HxT#o0eEAT|hWpY!Znd?MAVjpq<>2mA0HT8FTtlOy{GPmXy9H#_fZUfCkmZOtGRY7(zo0ty3eJ7Rb47GdZzUnAht~s!T3^cXn)v3C%Ui$PG2r zsfSsdlFSi$&QJ7yp`JU(Ds*lVc^&&XNc+8|CSk1DaI@Ne#fj3mt&O%#B6SJEWJoU=ZuK(GT_py}+GEH){i^V3%ba zmdB5OuRYsOoGEw6ww_{JXY$8i9_-6exPk;70gpenAk+j-*@~@AE1{q`e2X)K2(h$E zJp7fZPYn%4Wfm9Y0;uv7<$L}uMOOW|=qFj@gSb+SWNISmr*}hzTpVW7QXC6=;-Z6tbTk7RzUv>l+cAwkVvQbS*;q)mKx2D>yB(| z_htd_syf$KLFLAkQQ6z)AH_H`n}&5ljMv^Zww>L@DUbAVygtJbO0$SX{4rIk^f|d| zWS3V#wRpxs417a=i$1lako^bpDYCRV*yZxv*EQIsfIi;mCm!#P!kmXEY%3p^H1`Rh zCf=87@2*(}7}9x!d%-j2<}P(UYLc(N;O{KQ!jHyMoWU$%jK%P^yBh_+e9CWCQDt&= zF2Oi9{ah2UL$~Pl9A;}5(eD`E=v15{As~yT)liGT9FyOPtb=uqYVm2-=pxoRnFR8R zNYuN*W?w!guExU87AVbNmFRVB_W|}=FSZ1i?bRaOJK8;kOpCG#Gaw#=&9LrsEY$L{ z&C;Bq93$O>Tp~OmWd;3RYV=#l2sXqVpZ2;P4EY?P*xB_=gFZ5B(`flWQA+B9@q0Tqi)BtG%)?n1 z&%!#u7iG$F4YE`vPizG1W%%sB6oR<41s7X8H5OF6_9EMc*In#NlC#aL7i00QviGx# zl&@Q1FP`TReA%45W7GHJyq-#-Ru{BzNK>w(9nRAvSxs`eyspDJcZ0LE7siHadh;LA zXoLycZG9|pHOt-}1_p^*1&@{RZ#pGTkt8}9A;=RW%HwqNzrbNn1SWDQl~uXD&MIh1 z(x%AGi9FvfkDk(d5cI}YYD}Wq6nJ%2n3`3}?{or7OD*c>n>H)|EG}R<@%!Jn<}^kZ z2e!u5nMo%X*a&>CzfxF(=I1qM8)S+ep0S2noZqbcQ*g2+ZcA(DDMG3|1WQ|T3AG>5e%Yf-LzZ*4V-sx4GX zw(Hv*Aj(o{4&S*E1gjH*1Ad5cHj+IcZ-M?>#rPB1^mseI0#pk+VBmo&)~JzL;s|Sn zIWwD9?%oXD9AQ}=i0%9x!n1t$>fia>s;J%bftBQv>z(SDkkE|4y*bs?edK3poCox@ zz{o7vjPUK|1N>5^R)uUg74Bv%o*7uG;IxPvYG3uI3He%$>-M&y5JWrC3cV_Ck`RBP zGTxzbg;>*@`vu8bsg3K*qD=R?4v;j$hswXyGb-MgAS=ixW@2R#_K?QAOMd6Q4(##b zA~iyd!Qj2^bAe+r7nYN8vnay`^>R<0are@M;P&Y&H~G(M{@xgcHUyzqn|KE#0)y%W z^)d8Hn0o%kP`aIWuq3)<3C=k3UJ8T$ICo25^bYp$5lwwWenOj&K>y|h`8=EtlL6(Z zh;L2)#8N~33eE?AZFZn&R)bTpcCA4|AN$O3d7t) z5G&14k<#T@Jcd#Fxh2jK%2b1wexUHJx$DpG{2+D_uDfl(2P=eR75TkX z#rTo1O}yFGIRR6%1hJf`7Y2Ry$M$8Z9^mkx|*|{ zjn4D%5vs2|@x53@p&Pwx_;$#4^#=oAn3%RA$FHaId`J8%zT$P}sxyXp*+wvY^%ISz_blpjTzddtzm(5!;}rGrj_E zuR1mU$Kmh#*PiJX(*nCk2N*xDU1B)WguyP$hcCh2^#c;zZnO%x!-UluPV9~>4BFgJ z=ya39j~7`5^!2(H*mjYg|K@beYz^=Pd0NvRLa&HrGNFEZdF9wHk+|Ip%XRslKXm)V zc$U^*lhhjHBv*s78+7FdelNeWy5bH-tC&?B#2i_)!lV~5#e{QKAq7Imd^&8D8lLb- zGkqS=pHkH7&&{m9kJFvK`U?{3vD(dCv`U0nmXTN-dvEs0G+ksH;aEt_vv=#ZiSAX; zA<;gZQWD54y}2n((xz;+wcURn6w(cx*iNY9`=Z%0ZWd&!k=o!&H^C%a@hk|8L%Q=n z#r3MkRo`#yuCL#SRw0WK+5fFyrcE(|0HelgI+fc|jodX5L6>Zise}LUoui2B{pVpy z=m#0(CEVrj{X?Im>dlw`&Lhim9J}-W^Haq!DajGjj!z)NLZ3Jm z27%_Ac5p)gyEKON&-;VXPYu99VfpYahDh~T2~opEnNi z4Y~sD!37&kf1YNo`Si{cyJ{5rMAeBnx&`?o>q9I2$oD3Aw47c}pX?%wh3*BCDJ~tl z#6caVSlv$9E@e{w(U#AH5<7i?-vHg-#evUEl>*x){<_UBNSFi%rseTq#0`MB$}{K972i$|+uq-txvd1xWV?~w`tHIE!jk#c%gEH zGdN(l_Za_+p3a2PfcFKCj5n02kE5{&snj-y3ik=*;?xIWh-G8+Aa*~-?9}x}a`no0 z@47bq1SiZ^v^YyT!rQHaSyyzIhM(60{T>hv!27r)TA~~JC_Jz)(i--7>)8qKg$a5f zYqTX8XmXEebgeM`+|meJf_>u#)zb@T@q%vsjaQkv3utrqcMmduo4~NM3xD&q``EJk zcW#JBs}%BO8<1nM#QgBYEr=NM_Yt&w-6I^%doLv$u=ewu40AM8bB{rtp+3yFzE_xU z^k@`ytb3{H4<%N7)$s>^ruv_RMp=YhtFZCO-Tlw zU|x}BXTyuY}k`tXBj^!fvK3oXV2}cKlk_*A=qYvv(FzYB@9b~ z-&ATmUuF6v+s5CKjjkwDq&6eiRmMfuP7U4N-`e$G8ujUqkm13=MEs8sO9G#gu`|d8wy~03@Cvkn-)$G!wUiIr0M{=)^FsrO=|5b3dj{j= zhHC*Z(1e^^0;9Cco!pwp-|Xs>JCWq`%)^o+OE8U2iId}K-dz}@7i0=MfiXi9BNXGr zblaeM)?Dy&?mM5yf}fm3fS5W_h&)A`XWST{m@h2asV?Cb8293Ne8NGT{4SoPS+aE+ z@C>G=#7d~1SnnQM^xL~WepEa z^?$zo|8vg6SNp#o-rql_h`{`ifcO7rV2J-E7{Y%E2LE4z!Ty(EaQ`LP|15(79{*?j zzdH5*2L}Crkbvj^*TEqFOR%5+B^bni2?qXOf&Fjl|7T$T^C15j*#CUve+KqHum7Kc z{ck<{&%pk-QvYXQDE~Fsf99_KXW9R^yXrUiU%+#jZKkUCs|E1a_5U*hAfT=h`T|4q z`ar>5;rpO5Hn+C2cdroumwy%BoSv_Go_pS(QaK^Nx%)%AHLeWIr_`Fwm$WNkzz@}G zJ(eoJ-O0 zS5n(6wg9SD60a&6!97&qtX%pJhq>;h{;B0Q0H5|yI^{jB$&qU#WGzva+oNoxz(!5x zLhN#&#;C+H=@*!8lnV3Hi_Xi2=|pOn@?-w>zkO>o=@n~V1$GnsUoD0NJ4Aea|_1p8ubRC7XIZlKTiobtbMxd%=0^+@IFN;Kzbu#CV^2!gBx*7}JucUPb`5Nbpx z(LY$+gMI}?>f3fQEq-GCiW(p>tLk0W{9y$H^Qj?PO8i-&d(&@?2-s`(j#+(M|3nG*~jG?u!tKr@YQjOx`k?%x_*tfHjR*|fzQS))U>+IjqkQeUN;>cxn+Wt(n^ zRjVIR{2k)|vMhfgtbejj+MI^|9L4hv8=)%wUH5ZT2+%5SOm9{plkB;wz2lGWJ7wG= zK1&%|hV}EoF}}n`_GDFZmRda#{A5(77@zLRXo%)!1T5k2xI{YWBltVCOB8G9Kdud9 zcA%|)4k%ZR7^R=tXyama+!%=R4OS%_Wvf(xjb{vvaxKuPa$-8r>MrrmaXadsUY`fr zOwG|YarH7k=zJ<(|X8@e+jjRI3~H6*R|TUS&28NiJ^{DAN%X+j&0O( zB*^s#q6jiSC;612^ZNLJp!jAm^bc|0iJlTXv3=sRt>1E^to##LX4g&GL;9((Lkd34 z02L+*_v(;rT!K9BUanoSh9g}{bcbEHFR0KcEyoQHLb1py_ZHyilvG{#C|4m~PrW8q z6J(5k%pmHE_>QI5G{q*^zf1)2#4zce)txEX0x9K&lsTwIvvLDtb4zn7*`yLOrcHen zg?orYpCO&16}*n%Pv<>}KYvCa!JPqx8I6*n-vnFFP<6#)WVj@FdXH^>6AHc|;(Xu5 z{1z)Pa)X}`B2G4uErM$r?qN6X#1a`rv4%zAGm6>unJ?2Q%Yl2fIr29QhKvvDuzT6H zm1##odncQ8NL!$jQ^w~5>lovjW<8>HYN0h&ty-~ajv}B@hc>k`w9z{L&oessA%RsK zs^+a-e9?x`#sjpV5Hgk`?ZKs_0I&aQo~|*4rY0%$W4{5wq*Le^t0rE(63_VI%?fo7 zZ;8kg0@WG}2s_C>I+15suhhnW=$$bIDZLI9KL{}^6)Ot-;d?ERFhzxX3^I*8Ej<}{ z@-w%N?NcZ0&w@{nzPjO%LEU>N^5_=HExSxJ+VwD~nV*7?VGGz)fV*h~^dpLbZ<@wU zDDI(x03DcHrCCOEgq3?_5nf@!lu&=5*Tb)dKMAu`w#e3)HK`1<(=1LO1^S?`hFQdO zhbTrlB&)A$TkKt#f*XFVoKuURjvdhN_Te6ZXuwB1DQq9+@F&SAA)!zwANv5p@!}G9 ze?lOjG0;V~`eA5oVv9_tVD0*X@AJn1Kf^tgXQV9b{4D`R+>NhPqilVbNUtyw0O_(v zNxOXhVu3@6Q2|VFO~1Cymg6?fT>>#zaRLSGX-^}P?o;(wkV=|PqN?GXppUWzsevJG zQngeIqcVtR+?i3@M{t*1wW>aSV)Yf?P~Gv79H&uov-}W;SCDpoT>$6gHU20QG@SsH z6z2}TY)*~QGTxMcdRVBXHljfaF%;BhvvKm@5Tj+tcnRjsN7rg;iB;Tb;c9>l$+IH8 zadpAJi0@9&n)=jI5Ze&!gCP70*?WMUKo4Wx64mw5)*!%;f8ZIRC-C)>Yh;sbW1vIc zI8Ja}rfWe#gB%|CTn7lfJu%f&Crr{ie?vYgwCAp9;q!Q3KOk47~ zJ2;QV)eN%QJa=QQ#IsOtt>VJ^awu{rz>fN1-nM{j z0%EAte8P1xA)%gb#~4}WUVn7kQtjRAk*)TC5iW4`kEIEQTbO3`xn>!s#8fP)*6rf_ z>%RVyRb;x#&s9kj3xm8qelBsmgRQ<8Z$cADn0-u`Ph*rVDkw6(QMdy#0FO+xa)w?J z>I%_Go-F&vG<}VxYg6){Ar9$)1$giealw0rcy(g32aaLp26fhhz)^$IHtM6wzK!oP%D+{yEc_(K6iv>jPqYLWlDm5O9N_!v!jvrN3YWLQGf8^ zEWFA?k>wYh>EHnuaJkL0?34jb^6BA6wQ=Bm)+%H}Zyz;O*+4@Gg1@Lo*X%2S) zJNNxozMy^N>Zj{8M`G)WFS7C?p7s3R!9Qn%1$FYW3INAF;FYP9ZiMGfkzNrRMVmw3 zF;2l{nxQS^KbkHG-;iH1z6ch$Hju`K(Kj`CJ2m4=(1&Ttf3zxr454%cBmEAXw0^Nz zF~Msu;ZUCgS+toXFT#ulHPrh)z!O5=hu3~NTsH#P^;xE#4iEV()BK&Zk49o(ef%a# zwzdx!-$1fx9`_(5U&$~g-i$LJ-13T zCo#^sLyc|bYGRnw##1ahymClI1zn?YceP2}G~`+RpDq?sRV-XnckAeN4R1r+2PJyD z^D}t*weuNODl~+6bjfGOK5v&vn}qw{P3l}faZ_h$Es|_9XM>vwku4_{9D=UV>%YPsR>WvMQdJlFXgiuU?t; zc-@;eWrai+ZIT!Rn(72STV)$pnvQ+ENM&h8twyGGM!tt%cpVAX8di#>*GJwf7?@Da zF7sxaWlprnKVGTKBpu>G@BpIuS*xj&?a_|1eU7A%$#!`~j5FD+6q1WL^u*xr=q8B! zL4LUeT5p3f&hj-aAY28%rcOyXm*F8D4pX--_^8_feY8*BPa3qfLR1UTrYr;R-K{0V zrA#Z>3G|C&99c`Kb_>cXn4Gml>+~H4iYSEk1iF2^dxC!3Sz)Fd2X>wrE|l$GNH@Bx z^orYis^tf`jIyEcZ}b^d^32?D=;gco{5}$H4=_i^iL&1!rF-4_EeVVz9vW)*5Trjo z+~4s{wj~#7HA~9TN-n;U_6-!jFmUWct}#I!p1@&!@vIR&F)nOyek1oP5d4nnM4s%{V*W67BZ0@*de{mD?DA z1gU?|usX(HB?F=R&L#4x$Syo9#oxC&4ti_%8C(1geuPT4Lhv48Zj;x%vGsogISxJa zGZbWfUFVB-JQV0q8zw50M~J&2YQ9UPoAV)ldS-GBdn>nNjokU8jHVT%6zuhK2?^>O zk!HJhM!I5!uSMD$>ehYm9+w_1^<)d| zLe}nvySs&J1m&M{cpAe1S==DyIxogP*VB!cpQmmxGhFg593&NM2-d$wJks?1x`w3Q z)_-4hLJgYWh}Y%6(!3EVp0xg=A$K!HWhaBnh@ zE;1~xldKOW+7*3?HbC4fm!Uoo_{91mJwsVL_}V!VQYet98U z+}k}PZRcocw*AiSo<@Rm5QMZb98+VZi*mq{2=!w6%Bg+*!La2MY@0F3>IOByuUjPD z9MEutG$a5;=M#}`dO*-3*JXqEL~yr;6YfZ6XUExVVI6V{lP=uSYY+sxEO2C8SY)Z) z(#NU^Y@Gtzc4-#rL8v?5As4AvuYyL|W~)~}jtBwXaiHG9q<_%Q`taYnOtOj$v>KL}MQffq*927aeLOfP$}}UK zAEGt6;eY2@m5XitAD;G5#M?jc`YHX~C)orCpfo?U*BE^2Faby-52d&bODyC)wr3?; zC5oKER+B2lxe@JTou;^M*61>QAe2;rpp8x%vy3al)Hj&_D!v&3OTV@;%2T{udINm@ zbV;d|3@Ceo&rZy7jR+{U-mOY=2R3;7&EulI)EV`PS1Iu{FJh%lW&0YNkmZdQR!iK|bhBuvPyVTBlne7P%|N04jN}BDU^ce*wnbOJZm3 zTLw0_RlQqsi&!JUmLDa{@GrSXt&@{1NH_9?E3`I=A?-44LmZhTbG_5t_$RLrc$Nho z!1l$vA221`td9saZW5vgFUBzbMF<`wInn;ceB|jLLhiDxaRXcZRa5UwaVVOdUV}>nq4A%*~*1a2ahn!k$_1+nK4;p=iFvq&7bVrTzNUr7G39d06P~ z+HiwPg;;mnzzVI|yUvYfu|C4<_4D)Nr~Q7pM7Wjexx}|fFH#Nf)_8F7gVmh-31XD9 zpMzTvYcEyzk0-hOotRnDSGH;N2KF0`V#aD78?$RnfW`j2L<5p*OxU65_fSTGMuO+k71H`KiHQ$K}t4 z6Mr0?Vw5ytd!caHuZi$Tc1m2v2#_eXJl!ZW`m%U_q8bzs1`7J9Kx}{HI`h57FF&I$ zf?H>QP*GlhAOFq%ARNylV&c>RR)oHM>}U?Hbi~6~;2TZPsW1z(aEki%{4u|QYQEfA z2HeMiO(AE)z%|Q=F$$&vqMUP$@;Z;ga_PJjdpy|&r9Zo;7q>K&&n9JoDxLC`+7S`D z>RY&p^o-+){S@`#Z#Ve5WvvCFM(`bGtMMb_)1k_mL=AI=I3wdk5nCp~-YX8`P4c|* zk_)oL4yeG7%CE}g>p1)Y(oI~lbFtstC~#QIqI{KRwnFPkj7s1%rD>~_;_pok=)MSl zxBRe*Qpyd81$M?3PtHN15fd!k{IQN(go@QGlx>sC^q7W~AC?84pmiJKZ4S3Mx4Jwm zC&-TfZnQ}c>vkKtR0F6=O=&^(cwT`%?N9g5o9EjIJ^v@rt(I^WcsE`}%%?e<1ezo1 zH<_iMX=N*X-n?!btIZM69igk`wnVYS1Tg3wTEJV~GhUvxLWFa<-WsB>|COF)h=sIS zntys3|4b8_?sUUdb!IKnKA7@5^7U8GVK|pA zHSB_)=Uy8hiOmSU7+iwE7g2#bqJc^c$ZmG<--cKZ&S?1?n1uvvX~Dc%76hV`G^(!5NS=z@?Qaqr_()wP zVv|>TgQa{!X`DCPr#3`WcT|1GGT9V_y~iOHDdT1{q%Ol)ybi{_}8Ww@0Vgh=_d*g`hg7Mk63(XgAKrC;WKZC*TWp zx%W3RySU3IqFL$B>AnF8r50q$H(TRVy(#U*V2P#w_Q#efZ5=0&qXQ9QoyB?C*OWIn zLj~@B(W6}i-_~fwo~zTCUg|^@wvO{U_uCcy2I?B+HkN!WwVxjq52f1TchfIQ>~~H1 ze}gl zCBIJi>6_#6^WtT<`t~^b8)K_ky{(Tpc+NlIpOge_j@eW`Y2`0<{+S}0^P?q#W46U zmbEWX51mXSvfDMrGz$c#pL=#-gbUh@;Q>6k6cE}V_rb=0rb-_Dw+%7u zy^j%2Yq7H{dTIvRlW*1T<8L8(Qih?_Z3i_{)AzXlz3|Q$uKuh@C`h9k-t65R-0h0+ zEgtq)roJru`{CrdZ<`Rs5&n0$FaVNUBx^aXJVm!xa~QK6kMw%i2+t#wuP_f_X3Z5$ zyqRRJ72*puH(@~T9f08bEg1bxbb>WOF0!ZdkjOmj^F?8qmZ>)>iZ?R4<8?kXE7XI~ zWU@W2l>#ra>~mZ$GxRHoPIOJ=vd{;50etJvh+BS0L942@%NX=A&6*)2uBwVtgW9*s z7uX;;>lemWmEI*nkH`_>Nge!e-#okS7r@=^vg<6FHLYp5Iy1sfbIsmO>q{qng4#qB z3nn#dbQ;C6g^&)y-NP$ zYpkEHjV{C&o6r(*)-BRK@~}I;0N<=?5$8s?!XO3vcA4%;RO2eOrUTAqT~a8iK8EjD zN?(Fx%e2#7Y)Mu%(zekOZE#Q+Z%IyklR0YKyFC!&%o{BBe&)3jX((3!WQ}%;M@S=~ z;|~2fPFCr2@3U_N@ixUM@4x5Rv>kHX=3jg}PtqI3=O>R19Ammdo1gAU)|&pzR}^O= zNhTr0<7=c?c8p)O;xhM9Rz~#Mj}E0#8VVil}y;$w@Y1`588* z!IRgti~O&5uw|OCOQ7?w?-or59LPZvf5757x~?R{Y`VUsFG{of)+t}ik}cvtMl_FO z30yOJ?Xba+IU4F-Wly}ppwOaL>m|L zRPzDsZxKrJBL!nk)1Vx2;*hNS`SJ~}kGO;Nh%FLS7*VcQ#_w-3OIFIsGz|$&(=&|5 zn7g`L<@kq{svqv_V#svgYrTEJ88!Uf&QTav4AX1l?T}_^gxkJPL!GoL(Cw^p8f2qd z9zRav>s$}tY1Hua1~U`jRcWDqOLwS7Iy%WDOGxV+icb2(6MRv=z!&IxD_7Scp3EHG z)yu2sD#g&ceK&sxW=+SBVJwYr@0}0fxU=b)z+tx#DVzZah`jj)JO%O%FLgiN54nv) znET03VNddepK!tflpX1K-q&9yO*pTfR1c)Sf`yZMS(0JO&8^~R6qQ9`m?f5~u-0VX z-XE^@ghU{}lVrF@6bWNhMJv(|QdLbo*)_wes6eJF5#>Fv`Vf4H-Og?OH^5uDU(ThWhPEd zUf0ulhPvPTQ@!>YP;~QJ$3W35!94Qjt8 zvi@(RS(ZaIpXfOnUw@~#W>Jr{dcNCj>%m-1|EABlfQXO)mU%WQu|C}2e+F|38zV9t z!y9l8kRF7F3030U&EYTa=_N*hA+EHN`j~{8uTECOV74)bNb6^Kl1v$2$I`2K!niO*0I3#4<`OKSJJr zaRw{Zdx1VGh1(0!7Egtf$z7-74}cH+HM z%SO37qB~MGkpp)uTHd5nBMwyjq4@Zu+AK;r_*D+l34%%3&t z3=4=mpr|4fRA25r8_`$ zk>iO^&{ZcyD+~ZY5Nihay)`M5HL)i<(~aZ2j8=KUtP~En15}u@vSkFLpZDeOm@dUl*vK1EYl z2;eirE=|MV4|U)4-;k75{=Rr|g_@gck(z9vPiT-+np}ut`4i@BdSOg}JyN1Myav$@ zK$=h!-PS49EG77^mW|>+Fs)c~rQhnZZbQt^BU*clP7uXIiY+(yJNd^B`}vtgT8#qM z-41zZ{opv9{%esl?C|^OJ#~Gjf78WYV{|qqbZtOSLt05**dT?fQ8A(h2khD)=9IN;Tgv)^&-aY3bCC9 z!u!KW`diz^ICqbyPAJ~y0^cM4zLLKoot!9WBB=rs#a5B-H1x zI5Z*F$!_m6q*GaFzH zY~q1QEl;C(nI~P{KNWxkHZ|FM_icl`@_+Gmj=`05QNNEfv8{=1^Tf8DOpJ+b+s?$! z#G2T)ZQFKo&-39{-S4;F_f((iuKv*H?5^Ic*IxMlYSnQbH-4r`!;H@ndpFWep9r9C zu3^ZwsaKk|Op{lOua3)QfR2-GJYIjq`PM7t*v!s%%IzWh1@{>vh%@?y8)KcvRT2;z zU_k&0N1Kt30mVA?`t1*h#rjQJOsgMn(=0}lNb%H1F^Qv?%}lJ$zey8g263kiyMLfp zJUA&&?o^AdaY6fl!TxK9_`!)+Wbf+X6~#`LF(>=Y3YdgA;us(u>J@nULEu)pC)YY= z?$%$KTd#3rLn58h(HV~vl(p`@aDPa%@#&}con7Dv-z#LnDVTfbQihd(CGgT4M1gCD zyH#WT)uZSb%S>YG*7cSYSh;O_*mO@@;-+XYBo7T$h*qMcIyc5>h`dYm z5eoNmfNdXj31Em5%u33 zX(-)nYZ4rcR1fRy3fBXMVqLW;qz-NOz1lhPpAb6w#n-fQc3|?kW-|$UDs!b1H72>; zVz->b&UNFRCb_l*;~b;%y=zNY?K2{b{bP0k*U&DJBD`>*e($>P>-42xwq)l7L%vjb zPjoxuTD`&w`P(h-{f)6`v{vzpx!+~E0L6pL3*M8`1)Luv)5`W|x4B~YFe>C4^Dy#h zxqZ9#De^F1qiT>K*0Nx9Kfyn_4>$UdGx1ToYcTJ(R*Z7m6bo|^z9QTq&9BvOy^CvY z47+*+3-c<}+a_Q*B}<-(T+4QUblKY>y4f=MHwvrV{qz30RMX3WSK!<7dyon|K(&;L zN?t-UmzY$~SJLt*9HDaeE%d1C{__A?idClR2V#b8vn0=ma%GpSccc$Qn0;e}PUY*g zcXkKa`87``pYu8GO4hzz)T!B+8XIc^eIkBrK8u`B;M%Kwnpm6JsEt$o1p1l6jPLVz zSMC{l<$P^*J4AJ!Cj=@2mqZOp3onwr_^RuU-7Zo7L-(VTfY7c@k>)Iti=^!B7y9U2 zzGhL*>+u@p#?Otw;6#gQrQ%!%$ykSI)=6e>D1eX*^VX0sal?a_mdE=MBT| z4|ACFXBgc=y58Nsx_L)+TqKF>DMhou)-5`#2D zQbqTa_R;;M&Ka#XeB~kC;_)a`h)At;lcPSm{~V z`!|}YlIWxF6@G!~DqVH2#5iG-k^lK9e6B1kH{HY+yJq;&8rB2U+6C~|pvgQyzH^6` zsV{Ww;yORrC5|ofiV*y=53fm~R=STA_{K9Rs*%+xpQ4JXq8+hI_eou%H42p~8{t&^ z`wdzU#y)}IGkLI%ohz|}u2W_dGQ@L5gE54;KLWU$mt7dc-;;Q;z{G=a&j=Ick|CX@=dqJ=)l75V}6#Imp!-tTu{F z|01T=p~TSAUzD*xl`?*O-55(4pO=GT%{nQ_%{58b;2ix=)_F|a~s{5 zS=_J9KK+3#Fj6O3rLB>_MWRn$mKoN#MjrTb8d(@KNV0UrTuvEC2QLn98~l>_M+iyqfSK+oaH2E~i?JKuJ8WHI@$RQN) zcKwy+9phc7rPx`o=z_FPkN@Nw&>?G+MzSzT%Cuk|g!*s*{}d+Mu$K8@Vb?j=PMH;> z{skLkIo9K+>9>PhoyNHeb$o>X1v@ld8j<9^eiv5}rq`nFAr+EWfplF=%J zfw=1nDbHZDrBob;|8swGEBUkYn?TrhcTxHRh{{j~Jm2Obf9PE%Nj~q($}9lh@$>o( zm}OIFt;_@geL!voMO)#P5Rnnl#)np_qfmM8746_05bW72T?}!~vRKfp_}uDABrLO7 ziDqa!sGUf^0bq@z)HygSOZM|xo~%h5e^uy2nu@i=y?x_W&Mz^fyg+!^gfvPMjQvv3 zx+$fIpmmRnXFWi_H?L*J3}9Hkp>;oLSD#hr>h}Bf$2;f_Q;b%pQsYS?ZG>$V`J5!% zr^wW-MzJ+o#3*QnW))SdacrD)on$=El3}*qwaeH!re!m&`=rT%E}5 zg`Qv8axHz?Hdkb0v~;5oe=mXI7vj{7VU9>kkqDl;^hK$5U=Qk{`6SLWwan z=SPrzKf(ky!1$U5<`HU*W7Vv1fh0foo2SfKA}7O9Bx(oUoah*O+Q}l#{a=6gi!*CQ zk=PoMu)Dao2*)i$o|o9i#w?K%5PH|4b?;`xb3kDGV0KT*iUoS^ zri1Ccx>fytrF0+JNiN33)i$^2Ab{rfCUywW`N zQRu%lON`Tgw_#lM2yFv#Fy%!M8D8YJSJG@gq`APEWdHOJ7SE${&aiiZrd+J6%PzIA zjVh%&g>Cxv;-Ac2`*%%(7pPoqss)R}A46?&gr{VykwtL7J9wy1V=~Gm)P2Q=CdDLY zmxzKm^Mpj-43o*9IwjTG#1h}v)Ba#p+O3c@18`qgCqPR_V#C|=BP}@7o4>FUO_t`c zpvs{Mkjx9&n~Pr2_6pe+*fFAx`n}|rP1-)NN1#~gJ8yQXz*fBRVTG}Kh${Inx1{tv z#tQMyHpw%kR+@ODp9R`?nYJ%Ie|I5tEJFb}X+3q8&O%ijjDCEi!cIdgq%_9c%fa2#UI(vfP1Pfm}s!DM1=iK!*x zkO$lD=zWg?EA63L_RDCp2+EwbzF&mIjCm%s&aA0YZ63TW^A95UC6>g5{#%`j^YjxO zwi#QbqJUwRQHn3O4__*!>3##08dh0IFAAL(h~U;Lw79zXO1+;~dt74FY8=vgSI+nS zTU4sgPNceo`EbrSrjbo|j~@)%WK!Kd+utS1kL@bT0f`zv{hnF)=X4vS>zH3Jzz)Gq zY4q~&yoQN$4yg7pAuRrVauqsIylkRh+Fc))_2!nVlC>L8E}cFqbHMn3Z}zXWeS-0! z#uD!$2Jz<+GBf&s9iN^b9Kyl_VHwiJD%l$%Yhr%G-D)EwXDGKQWty~WpjVd3v0b0@ z8ubPow=WedSXn*^h}=9+4hiy~54;CN{qHM@@MadjuI2XFqkOmhQ0;O``~q=zHOdUn zCs@DG>6X|;X2*>Q88>T#a&2AU8X_u`GYjP!TNIpwMYpX}?VbmT3)9SlPoF>H)QQg~ z*+};{P*;DL-cX*klEa=>3u>G;Y2w{4XchcSbHiB;b^47KJv)Ui6Eo(MWxpO!{mb{S z-+1n=-|U2Mor0@lOeb`A5OrF+KPo|aa`;`mRYVhkxnq=gX!Vp>d7+d*$n7B-G%$KhrGs- zZEcb5lGLga1bjZ*7y#N8Rm$#u=+)5-?Sj_pid6x_Nx#Vlbz8C>6WRg2Z+4eNyRdcQtNbh*#?aGbyM!I0aTRe5)#{`}kI2N+sXNQa z)*%pnXls)w)Uz3S5KGk_Z`@WLIQu7dU##icudIn`d>g(Iu77K6qGd_vOskaZdxsUU z>_6lX%aGg%`dXl`rKjuABmZ?{S#m_2dQY>Z_lJEne1HebM!Cqt8BDdSja_Gu>T<@z zJHgK@AQ1tutiERcZ7Ixk@>{qoSzZeKvbx{`M)qUy<5mM`$iAzBf;J$npK48O*o(W zfz*xYq}Z@jp7_=*8y$Xx&BwoA!tY3_Z}I!&n&y zZO85>fqSS{seIDHj|W)YJMjXN11gobY(JsR4S#wEX8M-kc)HTe zkK}7p4}Y8Zs?_IC5Ll;3#lQCui{m^~0^_n>iscJ5I)8Bw;J|SADAb?t%&(Wnp`UyB z;1F&hc;rpKQ+yJ=n?z-rJfOkNDSp<rVfW~ z-_Fy`(LTd~c(nzX*=YPV%O2WJv?X47fO~?kRFvePWBP?Byv;Y2C zKDHRhR2x&0KehmAU$Dkho(7MyO8`plH5#HZ8laiezN5L7>TzjIv*TZRCf?d4D3jmv z92ylT%6WH8X2RYb5_WN7z%EWD@VtI(_Uxh6yhHqp09n$lQY}xzw5@SZ`YptZw?Ff9 zks`6w%YSu}an>15gmb=MNR-h@LTz z`wfwKVxP?kU@EpYkeNj1=Afv+0Rsq*Y}BN{F~PE)nLIfRb@yIIwMDu`w(MVp#00$Y zAMcPHDrf<|(Nq`E+G~vIY<~%Wj+nn|sz+25<$Ak0Xp2#%pA}oRl70 zSeOILdM$&ze{K|IlN*S0lWXl?lI!_Ec|nn`fq04Y6B_`(FN0iCT@yT>0WFeJEgrGK zozQ+_U33eebiN&vh}D}KZA2*R z$}pTN6Cpgvj2(f}3z>tU6}li%1_k<6inLgt`jwDg)6etR<8D<@)B|w+bjkC-Tr=I5 z?H5@R*{{Bc#?~N%GdXMheQ@pFYz=MM&{P=-YkC9r( zonfz$3$nwBK0K02$xC{=rP^!;=N!#Cyn=L6XoOM=lYfn{pxXQr$A(=nQRv2vqu znRa`vbEgYf?ThWN=p=AmWrP%IR_9k9rsi7Cm_%2D{OiA%RtNKP9N~L5u`{D-#6)*4 zJJrV)Mdz2snPf%h>!SXH-%kt@eo3{;)Qj*RmN~e{FhLg#pgrX>iog;MeIX-y3_C*N z7foFckr-TwX0vNXRt3h}4zJWsC5xl_#z$BdVL?c}2s_9wiD!BK9V+6;7 zG)DPgW*MfUf-nHWQ-wjDTAQ3B%GzhU|LT}S>c5_#pdGAX#p)bq{`mnos^J-8@np0g zKl;es0CsjIvt0M&!bt{{+k*R6nBS`$5xPrw~tOmxPOn+MggzNbT-Jv z>E?I?Yx`F-3d8F=7ZlpBucetC?@J_@yQmj1FNhE165BM)8q*wzn%=$gLsHeC0SuAk z;S!ZTi?2*wlyUU@H&}DmQ}l znIaL@z7PrjvTM4L&@#6a;lrlf1e9jLG=ppJ^DnUZlqjWvTuj1es}+blGzOX~S7o_^ zj%#*ILKbXl7~X>7fb=p>3Ei27OYA+OsYZ+Jx#S28L_td)IV_+hvKQT}B-?bDIvzPx zgZ8-ZK%(CUdf>ZXb}QBI$#puHtwGGQVqYNSJqNgVgiz0X<>Ma?aB=sE^KTE|U*UbE zSii9O%&tGrdacYouDdrg_3Yh0zPxJC@&!?=`7Uc04Xkrawte|+LQY!a# zJ|9`Mf%6lI9fde#bQwM$HYcjm9fgB&Su|BR{}g?YeF($ban-WpLG9F0mwO1_c8Dd( zJ7+zTAx)5Xq(DGfDyS<3+?_J57*B5D?PqXfZbh?@Ne+tw{R#}7Z@pN_tVTPSO+k$7 zuT0c9}D*-79@c4XgEQDVGHU+25Jgn8(wBkkNK-+?YLYf^x{7Hs)aq!)chM zqKck9B!?b7UWtq%IOnp|vNRKHQhhRQOCRV9?<6BcH~Ix7%KNKKMTw#Y)ga3Z2dJ}A z8fwGR4pbMz+hs}R3GT`R-P+;WpR@Q8$49ba0Lyeh0UQZ)%%< zZisn+Ym_6#fw&fF6zoc5TP$<&K5_*|dvKpB7NnlZU#w<}`GJpmafz*qf zAd|jzdQXxWN4B~*sHj$0L~_@@%s;exGN3RubH4>=F5DMYi2w2TQ9dh98{O2MUtIvNs@9@1B?;Gh7-63^vC-{vh7i7zT?5=Gq$Jc4~$+-QOOZSYm8{ z&B-RweSS(W3}z}Q;*_KhYe`6~@B|0)n_C8Oej(eMj?kDCvJI;q^*UAs)T`CY>h&}k-QpXR=C+aU4U=84pfx|@h_*3d zT+pq_w8j=$HDS`g52aUVgLk}X)c&_@W4(Xej)zeO=A79&R#x2fqi@k z{dc=W?45G=q+jq~5Kp?*Br4|dF%Ft}^alORWbbIT@3Qe&v*L|`9+7u(-UK-UBhVEC zrj8G!O(?*w6Rx2@WyhDs#Dt*j+mkBL9w`;SQNiAHhZ(g(S)Eg)1G9Br9GceM3#wXp z`!7x@pjO8ZN~wDGz&-VPA}gp$+3$|1{kexd&VFE}*4bT;sTtZO(v#uM%cl4l5`rx0 zsg-vG+QmCYfJ_X5M=`FAF^d9X+>mW4qW1Ns{q1fgJ_|dNrcaI?V zz%X_xHXgyr?r;vk*O(QEYENu`k^XjQy6Bfm{Y@HcpgR8XiCv;PHl1){Hjr7fi(U@L zzHdX7+QsDv?*Lzx99M6fd&d-oF;1y>;4mrdqSi^B;9MYs(|z%yUrd6N`MH(ufGp$71L z24zj`Ul)RRW|Jx?c7peB2>1?c3jPWRN#v$DhsJfX-&wiz4<5h`F#BGk=;MLWeIjmL{);}cD5pg z8_jEF2B1(3OJS-5R2vKxLaECk+y0QrOdF=__yLNgyK?mKmv7incQ(JJYdPd(SYzy^ z>JKj2$LJR5Jfk|H-KvzbOkbh|U!l)W2wm&N!hoKEJL?64;gwA10S?{BIU=s6_j-%? zsJu`_i3wcK#~tYa%j?fl>ld^Ziwe1p>r$RS5xJ^3);mY9}<;PB}~O3(;Ibm z^z1o37iKvk7+xn-)yDK}{nTZV`{4@fS|9CzVE?Q#4PFgeS%@y<%sS)7Su2aycNv&{ z(eKaO-?P^c%`7{*Xp71cXV6=kX-LQ?JTYgBifQqD2lhl}nDsk8uyv5@05{z*qg21( z$HUa@@>`At`4&9g7lCv6Dlt@EtTBH4l*|2+Wcad|hxH*MGXrwXj)oL8cm5TqwWbW4 zT8Aap&=%{|-!#PA5hf^1zdw9aPw0nOLbX7}E_>bwf=9kXl3j|^a!t4ck#d|Rfm@zc z*!f^p5r3pJsk$Mu!d$>2*do|D)qe{t&38KE2PNkJel zk5pGcPoZ4TjR=3Z-fn(m=49T+;8v$NRpR%H~K>& zn5A`hf4T?s5O1wwzEKtp#>L+OG&6Xb-qoP#I!j790tc*nE(gGHZvn!b z+LX|dLn?EN^_tcQ^b75p)|ytZO!7Pe$)3xd>6loDjp5U`DBi-o3& z6z3b)e2JP+i1g(QJvk42P&Hj56Mx@cZo;m@S!A5PpJ3D*PNQ%L`hwAQiK6w^u$l~w zx^1^WEy#3$qc9rb=k$U!Y+071NVDIWmP;Pj13(psCgxb=-+a&ZLA|byptn9akAHq( zyfY0W-*ato>>e?%fl{n7_oMe6qP>8VOv0JNED z_L>@NH4L(iW@|%)TnFB9Y?(}xRW7|qX4ww4%}<;=<+D`?B0WZt_VJ!*N0{fknq3C? z?$v}de0=A>aQ#(#jKWcuO8a8JOf+Ug((n&*4WirROjPGTnpQzp5$=+t>bUJM+@hj$ zm5JHdfI*24O>#YAG~4Or2wx;G@YkOz%hQ{50#lV*7I;_}ts#IB?mo;5t-obT@J{Dn zf1KGji!u$`1T>J`QFibTaC_E$!NS_j2q_IhyLg3x-8G<)6JWxdg;{qGbx7d#;Pn`l zK9xQ_bSMf$H{yQ&#{l51!{PMfoVbMj;{pb>1vQF|%B|Y@v1Yd>aFNAm3)U@6oD<-g z?mf{zc3vyhly~_3iq+p%v#o#jn1>=h3<@l&yQb@vu^5=gkc}(;H4cQ?Q|f4y z>7hQuJX&v;F4pF5RNj4J05!agn*nY3le@_;yoJf_E7hb)>hT=oPVhE$JB~ce;}G%2 zo@J%gej|Wsca0n-5^b3w#PMGHD^&;kh6H5f3)D^PxNNs(CKj>{xmuz)fqZ;Tg}MpO`)f)Cf6F>G$&AX!pl3E&Y-pq834(^mwZvTY>w9c zTHxB>=XZFR&&BoFHq|~h{T%lop9G`EHDQ~m)5ZGgGxh=eegxxgwZBmQ41S@$kli$( zQ>gP}KU~rrKm<0<{Pw=&7o!}p#Qg@NJ~!#(ogjZ^+ES6=5ZbL!qEw6;BAyR=srHwA zWgj0@Jj=->JF8ov<@~wRuZA(k(In#pQ4blYbI%k#C#f<-dSqF? zfx@xqLe|uLO(_o`PPG*_a)%0QNh{-)@TL4ZMn&0G{!6;yav!1ZTRzC}E1+6WH|QcW z3J@FaivMku@;i%s^Pj{F1)&}7N})Q#LRIeZHQpZc*lqh=gY3~cxvmA;Fv-fWBRtY~ zk>)x2Dw#{9auxE0hf6Ck?lwzpX80BE`Y)<2Bxh^qT%(Ff;a%?)+#B98!WGP0m4ml;V^uO7Oa3e7E|@azjLXRYTu3GV6nG#+oRqo9Hs#c!jjZ{^a6)JB8@XFG9V` zH$pv-|Bv`g=LpANRYwF?g$U;>)JRt*xhPjEwWQy(banE`m$JKMW_p z!IWWsXMRDosGbzQzaYJkCdoG4;`bx46c98zo*lB?qfD{5lw&mAhKLYNWqo`+y8n5D zI42a!_AZVTvx+jQ4sce<`RdDOr2I~v+Cd3ulYRf(adub_`J zaRLAt#j0?1uCF{*Mu2V-o3~bvw&r7>&+Qz>rNB(K)-b|Niq*GmSHp$}*hSPyqblVX zEDeWkV6T2el(X<-%s{xSVf+oiIu-v5I3fDD$B1XfSzBZgFnSqw^FzElSzLy2njGdP z4Tf`jnLz}=Vw4YQ7GX6_RcXFEJUhA`j*P;nX5}-v9@-$=%mpMlC+8u(5bsPf=w_oE zAYW?bf1`mjcwXS$psb2t7+)H7Z~0kbwTc#H*2P6QJ4R;|t5iX}fCRea5gp>-A1PNc zjj1!;NjQ)#?a@MEx^J~%&?6@42E4&c2Q{2TktChD%E&3DHczo^x*BbWq_EnLV z`>R}Eyw>gVb(}VjdGlwKbB>X~jvZ1t6mJa+v!dxKO{REvu z=Jb$tqClw%HHj|H7Vltaoun2BFB*CM78vgK3?|ck@o>*<5qF3m7D)BExS3{t@)ZEK z{v65FI25Lrl>Q0q0PCdIP@~l*w@5uZ)TF!;m>CayWaujvP4bm5TO(1c-eMZ2dfz2T zkf+-_i;_G(pY|p9tNct!)9wHCUK1nFSd_LOFVNEsrCz9}Rc%p}6u z6IfVc0l_YlhpSo@Z~4Lg*liYJ6yu#OU2GI%h_u1PI8&imp--XFEid0P&K!vc*F@N^gF$RbTwd=FoWeSqWR#XElgcaA}Im)P8LuCq$Z z`Rb#Zba{#i?XrDjqnLK)0OqL89?TN>C(EH##w&$o*5>g1?|rqgMEaTY>Lf47OH^@Q z3avQD4&Gye5gy{zKJGqds|4IblaviIy{d=rM?ffdt^%wJrB;J{p3;~aV@*q#I`uia zW9$SAUwe*Kot$iEG#>S4u-K2Mzp8hL;f*q!y_46ljkVJEi2jJlw)$-mR$_ngn21iG zM>#;=zcD_bA4sxD!!TW8Oz{rR(Wa^vWN@Ks4ol8KeuIol+y zU$Sk~lOKj1qO|wu<7B54$Kk51hP`x2(X2gC#LRPYJ8xiw@z&V*Mml7&^E4@bT;Sh6 zTfdsaIQ?z)YoV{c60K5y6R#9%p`I4yqS=9q!`z5&yolqZj5%G0yY5|V<+w!#uIbbb zQCnrYhn=i5-)xO653b`LsZ!Hw{3$*^MsV^G_+v#3EEs;%*#yh=$I^&!JvzlU&ms+J z3+j;fnPZ??oS~*$;`v0`fYTvEw}yVhDab}(>HNU&H=L$rOh`AXB0E=rpU zy-r@K>JVWMSExD80qeAn-6$IcD+8up4&&f?Cz+h1=X_LFcsL(sYpC4Xr5*hp1FH^neHdLq= z{lPQMeMz)^x?CCLBvLTX2zTzAOhT=yLZt-cz9?5?UaruBL_+BX`~1#xYfRKxVZu5Y z&n)gSPO!GWbE*~!9s%WWOfL<#1@Ol}aVC0d!jxYlUKI z2tkgpcSywlvcmJbm8 znp27==3=W=s7Q98UyP$%j^kSB=lk7@SE%>!%)DCi*a# zmwkC3SdU=|;rUL^bxyK?j?pgZddl*d5NML-o;_MTR6=}hb8$@j@zsSclO&7&CLCx2 zH}Bkl&T#Y~anhnnQ<&ur^{aS{J>2001^#vidy#?X34tpNUFMJYwBdL%%#uoOtluU@ zs#Ucb*I$FLkJd-79?DI4dtAk&D->OC@E@MpAMH*8pqEC&-M;(`TO0#1!nA6+FM&}W zZn2&?OOOVB6$iM3p4`P}vPtbo+GI8SLvh+SVb!wSAv z`_~vRr60%JYhmFcd8%=R|x^U0Dg8<@d zzz6yJ=}-%GAs=&v>3{aTy!(HSu|+yZy3WuAP{!Bfnk#m~FOX2NSwMW{=oP9~6Nac) z^L!+E>y^LWDC{yM(*J!JTsu8NS-}OMy((3it~tatNq@TEy{=KzD*8hEu1I6l=$59Qi4s<*MZFN? zaf8D+;SsmS$SekQW9~Bz5uVs2Qg0^zI>Yw4?lOU6DA)TPoa7;0xk-a_gP1M61-dp! zVVNoVu4(Yya1Gv>f0StB?$xD_sH^qzUiXAr<@tJH>^JRqFO^nn80SNGzcAfDl|Ttt zA3Vfv3q3w>jPigo@Y1hxK2r&^Xw!;3Tz$^zm78n%YfHQ)mTx;ZX?o_!&ex$2`dIJV zg({bCTOYHWc65qs8psdnWrWWm#V@RQqUhGQ$sb7A8)YX5{`B0)Xb|VD(iC9bBI*>a z*G{F-tw=ffF?)#}hEVU~So#;g-&Y{9itV0ALYfU}$!-#L77;zeSH4~>U%d#;FV`L` ztNZ-G1n-6q`7*u+&ZTfBxq>AIw(;gmrOG~*N#RrP8}eM>mc#G|n~ZKhNj7VAn4k1N znx$I#8+hd^s)a~bNzOW166R=Df6BS(-91zWGF>W-cW0}FRQog&+TFfBVof*@&~&4# zW4Jr8k7613EQF9IDbd0^Ak?$R@5K#B4ZI(dsmT+?+XDUgq-A6zOD_#+pPH3nmuP|T z;2sd=9a?vcML!eeCHy2j@u2^OQSwu zIR@@#6#S4-pUV=ZA{o8P_5+YI8h$M}}1`c)FILkL&nX>zJc9IkK*0ZrKT#qchgnw_jBoMy`NP5;qy5zgMF#T(RjhXd_Sakg+SQH`X)UVZ7srrQj1*?G-@-P}!K z=X=KLt!_8u3QchBYmc^mfGM8fdp}34aL^YTjljveI~1fR%+txy=}|9n@Bs2fMDwT1 z7rRVyxlr){zi2HqBIqtdu~mTnhtCksEr%dza9eAr2UOh;!}Qu8ir5Ya`$nKL8AhQw zTnn8-O`5cmS+2Kh_v9HGY3;!>>|+b#i(s87nu?vT5KIbXDRzkh9ffJ%8sDWJ+HrXb zJ+I9`8*%mWF1#r>XZs7Xjc97aN3i|mm~!Fsq)8W;W;Yu>m&uj4#OMZ8XgMO=rGDK> zSM{CCK(^&Egm^qZ^S^zLjo;fn-tSDE87i!au}K#>?Z#`p9cti|+&jaCsnUL(0hp)u z2)N$$TdV^cmC0u^4H=hmj28r+cC%TD$h5BoB5f^ zj*u9>p%p5*0#v|4HEZ(^lG+$CA!J)C)b&(Z+NKZFlJ47ld4 zqW_`@CI~K8SYrH7%hP(-#so?f;aH{X8{?3&c}G)4X=f2@MP`VFp&xT!;>;pcSyiLF zAl%*aaLlsft<}p?>>DfSjDdZ%2|bn1Id?=|bnQ z5ekP4%Rg(xP$7ioh5A!0IfbRU<7`URcRzLs!Q7};hfd=Xru?e?;(T=S6RoP%%M~|= zm#HS15N>deGK`M!BOELeOSH;Wwer_~>To@LT~rOTN1`xj4~{Qz<`CXs0d;sCy4}8q z=JEUKjJcvn2OR?+FJG}VLfWRg^@MI}r@zcxmB zLhfDYl6-ssc~7WBngFkKSp1;>9 zm8-@Kj4aO6DOF21{bX(r-m@7~yK~_S^2h99{i%WUM!aXEm>%9NCJgO0#sPn4k?Q~bJNskNblPwUrtvPWw3~>1)Fh+p6(R!W=>qfZ zew|aIMvqNmn;7>H;<#aetAjUe0Nd0l`soMkgE+5z{cp4eStFD*gW^mF1Zc04KjwI^ z5IY5UreK}>7lhm8>W-1PhsYP)PnY*nHvYXdlD?ID5@8&u#q;RFTHZXVDix^t5( za@gj-QIYP}hj_(q;awv&OJ?X09BPul4XV<*Ww1{!F)5Z}!_sWn2H_=(lw2Foul<#p zY=tsjLBFUp6zT{0$!28gL%J0jAb25Q5$tNjsV|AX(3 z%6k(Q;~auPKmTdIEJP%NqfgI`kVibMoktw&q$vvJN}jbYCTQ9+{&;e*W*FQX)n=nl zm3FFIuV9t9KvSo%XAtk8TA4z(HpE~g?3XfW$!`Yn!L z?MjTR3|kIhGGre$KbKPD`WSyv0Xptk{9^Rwx>0{Bm?3Z4l zM>j${=opUYoEpQD2~?M5jYg>d>MFx*ac^{d<4z}_3p7pi`f;Pb1oL;n6t4${%s`?^ zsJ>BtiivjquPXhVR0sXa{qh=-_b=&1qstE;s(&U?f{Yu;(=61p(j5r6j$Z<)Qae63 z&*VCW$%>q+D1KQ~kHg1@Ddf0wa&vMmAzzD!o~^d|JRaz8y99TSFzRe$0+<1vFJ{qt zT~{v}T!lUC8}ya>h#>+y3jP=yBo1)rsQURVGSE%slI;>Vy_@ILIa}C!zP@rL4mq|d zb4-*|u@=KDrZEMgtm}W(jI!~ukV+#^E_Ho=CBks7oF7}J_IMzFbs1l53XiF|5qa~l zkN=#MF17zXs1{0Vj8z-Ujy}!Rur+R{KvTf6_^ z*?L-M4$wcOsmVjA7bYkmSc>rNLYxiy0uK>4D#+psQaZsM1j+q@HMZz>Y*=H571h}N zX%Y8xRJ)O6H&FZ(`5BuxZ@E+;#JwkiuH^+$DY6l4lSrb6oMd)PK)ep>zC=&7aM!=;fG{@U8FxI+cFU0>yS2swIZidMWA7=z)=C z=7~UC$FYwiJE?<5PShj*e`&(}k%+a`OC5Kpb}FcI1MMF{IEFoSNLmE$Cy5S57n0*vy^ zqFe>D^o(MnFYeJkmeX^C>Ln_t89>mPZBDE^UO=bcG{Y3{%_gu89%6~~;ER8s&K~OV ze$!o=6EK?iE5n|6$~F)qz$N??W#IPN@li>h8F5s-1AJb#nTWXdtIO;(cGf?eT5WkW ztR3}I*OPyOe9khvNY@l=nZ)C%(qQ{<5jdAxT+jR!T)l;b1EI#gx<*{Hi&b5fC2gUID0uy z1nIdc(y!-)kYfPn7Sau*;elLVO1!)SwcLW_=AP<+%W&vWZ?@I{RQ>-$to+~4|0rJN zKkUQ*+K>G=vj3M-li&UyuRzqxR4`@o{>kU$DlzryD*g%;;`9pf^vd@1Djpg6^y&)A zGX5&r^Kf|oyAzPj=v3WF;c9MX4!Uz$sN2J(UG(vB2@>Ume4A(*ZB(ZED5Kiu5UAFW z{-}|lp8q7#m^4j9EMoO+8wD;A0F+;tS8kfYM;%iqh`(APx2oT5<_ zcsy#p`mt>HFW=>O2CDQPM95}Z#UG(|()08Z>5VOCe!t-jH@^J^s%yKWML@NY}~ju*Pq^LtNj~!Rco0J$#|sOe5T{9A~lC zE3jXG;;lgME0xt~+jI9JY@_dyCrJ|AKjl0ld)X^>4%+7+7l`FU0%ToMfI`?N9)aBO z9Un-cWmbb~zXyB{GswrlKMde$lbI;%2M=_t)SL@HKbF6Dfwb)Y@&5y#Kw!Vt=IP|= zSS7TGx`Y*Jq?(ef3b&~jKZCIk2YOa29bk>JYUk(ah_nu~Cs{-|MSaQEkGJ_}zZqJ` z$Y(IomJ_raxF&Inq(n>1!+q>S%sd^u9mdHSI_-SNNcUi(jS#mN&@g+16X`FZc8#Jv zoPYA$c(!q^(*6+?zdqD?`>o;S>*5{|?6ycaL~atYvvbg!b$#V?X$(qJ<&Zy?JOCO*yOyTc4M@}#?&Anee#&^N*| z$)TV62hGnV)ICSHNqHaR^gn!~G}tqrc=Yq{N*M;K)9iEYFXYe?_~ zYPzmT${qp3tX58&DC3ZqyLm47E!9GSL9_+oHt@Va@$w&jw3BjEg4re9E}nHvoE->k zoZ2!8^+>GUBk-62`{*9#>DkZKDA_+mpl_0ua|nGCem~e(rRux%o4iad^(@KGD&YZ| zS-~(5&Mxa92r|m{3f9X%#ZIN7oBiR^GGm1Q67H+Lc=r!+o)O|%XWtEK@6ciH%m47F z>3VtLuYLS{eJPf7a|gIMhG1{qgPp@y$XZ0-uN7%<_xJKbJYFE3pcQE0?Vz4kD+GCX z1Syxl>7S>!S&U-{{rDc5adHRm5)t8wr|(Ntn#m1Zy74&kH9W)A+It_rEdu@Qa%Jdy zkDx7rCvcM0T-^^{|Lk9?mgpebMZG>p$et7fZz zFILAo-XhFZ6K$%OU1rJqM6l5%n5ShOQD#86aq}Pk6HqB{T28vWL=x>D?jC8skGv0u z{e->4HdgtuP)9REtrlY=@{3%nMoxw`Z@))K@uxw?U{AE&SA4XSIXbQh#j;Q3adyGp z*n4vf>86v^c$?erIR^>m?UQBe6&gHT0BD zCBGW+jv?REO~}*^6@y@K-Mmas%B>lU1s^ z_`l*ueSyDYSTxF|_|?X@_fDossUl0^9_CTTvmfYLJAaE{oViwZn!ZuO$L|=OVT!xoE_RrmefR+D z1*DT_l#ya4RX@ex76O0GIanh4A?&usAe|w5isM|T%B$0Zdl^^*W^grKGt}0e>j9RDq_BmG% z?HcS_rIKi|SseaMrxJIcdd@Imf^C5c>iHP$2sO*<2rk-@yFJ^)-S-6nYj2F=je%KO zq}vj`R#LjPS}kW2)SYo=fW2DlJf%iK+lK)z{$7trl(Wpgrx;%!$vVYQxYq+zpucT+ zl+~;LL4aRSoot?(Vg5LmUd|qzR%NOI$_;nF_;-q(8WF->x#|zn96Y71B)pKm~eMeEO~^Tl=a#{j3Bl@@33J#qZtx zQuR1{_t4b}iBoK&MpwFS0ju zkB}-2tbL=bG((h|ZEUTqG<)f~6=cP#K>u|LlC?P7ShM%PtrM8X7{{8po#HTlPxGu( z7=G2y_+Rc&=o;@5DcYc)MY+Y(R<7}(u|(wn{RP}Q;o_Z0Teg}+_bfH-aFl(ShDP2x z=`%cA|2_iC>`ze;IKfFZ|C{|yQW&P0mZwMU$kjqu;qzeyD zF+M+vbl8Vz79ro)f9MttabBapgz0B})qjv@p}`ar#tzp4=}IjhQ~x%tzdQ4IrUt_% z{;pcKX!9ziNv?LjN+Ij$1Y3)AwDls{A{pHA`*rVVtb;YoH~!_HAE40Ie7~|xvy*+i%+;yzBhlT1 zfIs=a^EcYzyP}_uY;Bs!1XGAxp{7QWLN&qGGuRS^SHK1l+KE?yW#YHbTmyxgT_5#wR*V*SDR2h04z|EFIFpl*I=5`D?s`qTmAqR=C(<; zfv=v$+c?CZXXGE`7d%ew6|jesq1VlKjLAG+_(`>*QkL|&=f zCKB;Rs$qul2<{j~x2j&!&z@}(`gt4s5<@HftF=Y?4!&aq(izglBiu1|imtQo3ou3R z{3Xf(@{eevW9$JA%BfZ~jF95<-reT}J0-H+dHo+r=V+eC+hUqg}FPBJn7k{Q!rQ!)1 z;)!l?oMVO-ckcttB*QF4yUZvB%$-T*IMXd!pxqmP*&mM(sHd!>)AV1huMo~qsufsA zPtY1AARow9WNWkaL)@Cgrx>UfG>U}Vc5z)p!Jq76As*jkx%z*<^9Zs?`p_lZman}; z5#n}^tW}U<w<`dWGx|)CmJ1nEPrO*-F2_So>0g-&jn8mC8C9 zN_B@g#u>bw_$!&Z1DxuW*=Et6awQpB06@09K!bjPej4f{U5U9}stXQ&4mr$i7ZDct z@~BY0N`LT9YM@CUnH za+o&Fh+>|*|CRp>0D55_pJa3nuU6>cd#(BeIZM* ziGPs}vzMrC5{|J{eFOpJpISv4C8+1iRM$Dt?Jf#1m@`x&9KiU+C_P=4A76;$A>%hadCIN0LAM11=@M(y8A@=IzE9l zG9`MS%B3pEwv{RgHol>}>qOTmDkVLFKZUDg4ATR>QcPd*UBlTHRwyyn6l>;56k6=#b9CM>3Uqu|(kUnS zrBP14hQ7@>z&S4Svrpg*ny;%%R5hP!xJMFu4*+0pvy7VL>!hVBTw|lZSfwjfT-{M` zN`L!<{D?5e?CY#v)W;X|rA9u^Jj)>7X@Gl$GSS2;>J%i~-beu(xn6k_LIBOgv3SnfSYUS*kyvZ^P`S zD7|8Xy+xbBZ>MNhDWGm<>Elgb0IWmOts2D*VwihB?_U0JFV`@J@oxGFHQl)G8x(0ZZD{1GysnY$Lp|A~!vIT%xhkVUf{6ie)c8XtED!+ki0~&YV2%{y zXp5*ue2^_&cb!Q$O}R2OXpqG!mUKnGgl44k1NM<(sab|zPJ(I0C#Tc|!?q8?&1Z-p zK($=C^nVrH56Yck{?F-RKQ)V>E|4Iy%nP-W%#_QNs;vCg>lGR$OR0X%lOE#r3HGz) zYou5so(!-Z;DF!002;;VRv(6R^Q|L20|=Kd&_}t+Cuiwm%{>F#q)Co5b<}g+qOdn| zbf2Nj6Jf8kix{U%<&V*@4$YHuwE@5|BU}H~16D|DACyKR$y=6 zuU*5_&6KHR8p`~nT7bK`LKtHq*n)g0QTtR8>9|TFT?YcjnZ%kICj-11CEA3c?*qKX zSZHUv`KRgUXb`TZ={E_p45GdWc1+V}8V+*SOBrTD{XsdKU^2*}n|Z~hUW&AT<*!w6 ziCq54By)-})pD8(VM$_6O<- z<;)A<<7b^>mi0|XzKm|{<&S$f?Pdq3R5MS%`xn&;lN{SDe;eqBUli66*H~DGfnKoB zg(^yAMujEvflf&l0AQ6O*!r5osh1q$V(uTntuuIs4YT_MhB;!K(9h{*3U$U==ogc$ zG5*Z}t3}`49Afczpd2{Gl1(c846*m}1pr^)L4M1ecnAKTX>#7a-$*<|VjWFlHDV|4 zmkFV+Apq4H$6)UugWPGF0oo4(jnX9Bn}jN*i})G3em($z^l<*a9RBYPx>TTWgc}ka zZ(k_0NM)K>sE75tK{3XKrtcSRgpX;=8PXM`V~l!@S=b9qh`U_P9uCC4-rxN9bD4bO zC|js~ndT2w;srl{$)+wri`0FT8wfXF{)sOU-xR-u%T!)rZ_xSrxOv6dM!Hcgx_KpA z7^Y+z06?a}cSX?uItbhNfu0#gAG#`)zI`s#{Pwv`c#LI)ZG_Fw=ZBm}P_#pd8u{{T zzh)2HJb4GtAWO4Qu;UaJ1dOmwGF~BI98u4QJ1pH2g)1 za`p(3qt_;^QZ~Wl5+>e+zwH?~Lx;WpimOpvp=|J%g<)3hC&P>>`gm)ity(#hbCJfc z7V4G0z8*ouyYbf5N&+3j3{Z~&0rA#?KgN04#ykT%n1{I>B42=G^mJ35+yz$9GyLH! z^D}s`qey+MSA~W{2-#MO0qyD#&AUyRie;uw(Vx;Enmr?5ef16w{*Ywl)$bTa^9+YL zUU$(+CYmLJHHC_gjRLJKE2rq-x1EBdD@-FQrPB;iZremubN8_Ce&0g-`9D5$_XD6Q zI<9_ymlq(*u=bN=SC#S-`4T4HexbT`tl+>92jvXpGs3=fUAuCjYOZpVe3bJL$2>Xw z{Sr0NJnQ%X%Q5Ot=~w=)DIfDKz5>7$-Irj-Nz8kNhAL^UUVl%8A2_$jXGvE2c^8lV z9zQ-=B|%*_h>LVRp#0sVerXmHZ<{4gFf@n)K%U+v4a!kJw@XZ^nsi0DSItL`5$Et7 ztRAiw!B}hbE5*inszbEWk9N_$g1Si$5MiE{sh>(3#ZHi4@e4Gi>q=EYK*nD?nWQmI zSbs%5h5DmaP^^x>Emg1h-65KAGuF-_I$QtuyAU_=CJ>NlfxTa@+$6q>3x91F`|UG- zx7-idTZiZbD~2i6(iTyaGncSsa^>;`s#DYmC;aWM5Bu2r*bt8j)w8rx^}W2<``h?# zA+~XqO0W3$$e26Xx&Sc9dHgQK(LHp9^cZ4F;|2HQZ zNwCCQKEw!idv{GczKK_*jk!Nc2Xj3`GfsPmQ)D#E)X&be;OiA?ua#$>#QA6uqhN`R53a4o~i1@s;|#b&MSw@-54FHs%CnC3Ay ztuq3>jN&&4>cv}y>|!&`@+?ihQZE5dH}I=OD`Y?A=_jj%B-$Mk1sZ2qOuyAjidD${ z&`;l{>SE@YW*?I+6|a_T(JFk^e~U*RnqDATCW1df{egH~A!8jqz&b<8(;4EBY^aw~DTBXuj3i%f7y3nd zj_eRkzKnK~WndEzbC+NRdU1%RTKEG+Km7n1Yu6!I@kyx?{rD~KfP(I4PBI1n$cH2B zX9&J-gS-c%VH(8KkF9iL^utS(cpIq}ypx3*TR0metAzdRf8h48SqJZtqO3mGekwLf z8Dd903wKVmlWdrw)h?p%Ymr|gw~g4tDOYEkbMXszRV-ncIQiXh>PHCE)eVZl61(tCf<_|Y~j&hq|+V>;B;AWbuOhakw+HpMW{BS^VSC3l5nfMcFYtp)Z^ zh%^2I@xGs{L#%I5h<%#TDLU_jN^vv)3~RHPdxU87*~>nGVLAvJ;| zkYX!bZ(h(XIzy$A%-hq(r~C7B@g54>TpvTOmQJ2fSGX(aX^|nw-8T&VH`6%LR;9`s z_74%#J;};m_DQZ-uT;Zurzj8SkW(Cqsw$~mW7$%%_AK2u{tPRTR-AnxK%-=YpJC*u ztW($>!dEMo=rZLlUfd1BGoJn<1k#lyDv91bbkQoA+EYY_^a^Rez;xp%$1UtLgdegr z3!(nB^GCP~L}!Ti5Tkq%4x88lUHUm=f>X?T>32^DIAAaM%byyQDvV-bAkCBcT8ZYp zLNAcs@c+O}u&d?00Pca~jO!G0tRQI7W}_@64*$c$n(Da+JD%uVxEiWMs`_x@IE z)QMW@o_j>YRQ%n#>8*TxquZoeYN^I=`hNsRKTb6T0XA_jpjDD={ZmxJ%~01O4aUh- z(+#3lks^&_boXGEF^!@PqEXgQ6(X&lD!xSF?Ic*~lBz4w2z9khxr*oK7Z}3PeSuIdGebtWyG%bt1Am2emg30W8RRg-{O%Is3H78v z?Ng>$;{jHVeu(D|A^eSbg2*7)%^JZ8ib<|dkU(3t0^X`y@H~e^GsY40UA#Hp&<%io z;2*q7>=bkfr<3!BKg@=FwT}q^#~7LB_*+*nY?E-;GEE;^VQ*`tax~)IT*J$ilI+Vr z4sj7Lkj*%UsZ_t;?&4;f95(0O9vDFv)0;H_q@)JI=0$ zXZJl?2u7{PDxvJ*_M8RXxo`Y{3Fu0c*`7(e8`TD3^OJQ}8a3DGR{^%L#K z-z6P@eF1?cc_AL~mMOOh#+2)5C%-zh3ik6`#4ljS2O!+V8{Hx95Xdx_DHH4n)!jn8 z;S09Agl!Rk0EWqO+np#G3{=Z(;j*Dn7;AI7YIKsFc-7m#S7O?PD9K%l(M4*C@g`@(c`hm9Ar& zXcn`Jy+C?^jBsifY84sa`e*;3(7TrpZ2+)Abd474;_FDds#agD7;i$p>m1a~7w)`| zRHG5)S*R;lp^>ju(JSzL#yK~_<>jGPsZa%eg>-~|JjmP6X_$|_*TehGpq*PM7yfx2 zYk-G-g{M2#F3yB-`D0y_(+W+reU);F=r%6R<^qjn9MaJP2t9|(iM_yWrGBNt3@jP`~}i4%3h9qZ4cj3-dv;aa(%po zpHE?^=im6p1h(>Ft-Sz5>oT=0GdD_s;-PxFRv8>?EXRR98dczgKDR2C^wPd$Pz5#eso zP6j!}nq5OHKEYpm2JYZdF5>Mde&5ID?*9Yj?awwc%1X516yf8?+Y5g!TT4BUw*&DA zdux~}RbMMRPhF-GZ&NGl7)du{oDKkpm)`zK7As^-6V&sfEjoE&F30Hq>>ui7AHGHe z2IytM-F?@?+|AWsm_mLOuYkXtk~G7i4mULz^eX_Jhz0!J8RQ!bXRrkIIz&C*_GID_+Y z%{0`l#9QCQZk6-&j&u$T^$Q?bhCUMOu>IN~@bBK2X~8^|=Whl2xtO~>+nQ@E<_ie? z{u%1QGh~cGwxLM%6eGgn1x6=Fy_l}w-|wf&EVEI@1GMCiNb3|!vW0sv$Oo1QzW~fL z;Qksl!o5-8=^pC&0Vc^T%z8+qUD`4R@+roi_?Kxq%~YXE<42qeKPQpqLv%ln|KNdb zJm>Jbzm)sAb%axvL956C7TgWX7{nvX*ceNwYqq{ndyd{6%)8$(cSsktvLK*Y z^bV##OQ?Mh$Ir*#o4em5NVH{+#y)0-&d*1^xKZL79_eC{a)F9*vQ~DM7V_bBH&H7o zRyasT*lu*?%zO1%JVV*nh>XoB57 z#x(sD9d}=WMvZi?*@wPzdG;xZvMK6fQ^JKI#zqbK@)}w4i7GAc=poi*{V1ze5sM7^ z6|9YJ@rNg=)?>^A)G)6rDE?0Ri7!#O+s+B&^jtlq`n$M-HM}Dcc7~bkqrpK03x4kZ z>_5RaM@jm(5+z=pq_<7U)@2)Uius|0zgsEq>&iM+sH2@T!T{Lqy_WBs3_5Bn8PHAYOXKL z8zm@~5HCr8v5h2~w+RP$DSmf~&?rJXsrXbcRU_Xi00J}%sOOc-;jj6-pTLtW!d#fg zzgoM5J%hntqn^5k&QsgR?BS%E9%6>LEmH1d8)PM$cM7DK{j)#M{4xpQ+%vF7s#sI1 zfq0Q+@>7s)PAk7mS+UF_Iq6HCaD^)Bx#*8tRrwm;Mw$8p%nW79-9;|;F1VK)uq+Mi zy<)BSFOgpmj&W8$wQx=A<~qbE7D3)aKC<-lkB8ZKI!lcA3#}4*hDg0nRnVFjZj8F=7_^&;Qhm>ZISVfq)#{ zKTwX5xw^sb@-=!n;7^Q`tfL~WwX(Ro0Fa}HbTPt)yE=0V3hGLPFt-p?DDCP_wx^VC<*IX0;#YANevC}+OG z#@RIUtc~50+76$U- zy;5iJ!FGSp9^nIA%j6}StHik`N9q3Y*Uuq5=52lk&`%GreSJu`(T?;om8#_$^wZ;= zTtf0Ra?ScV*T^Zy`~!?*rbr#~9$q{nlF!b-gz%n=Ngfpt9XYydFG z5$$A}`lfOVcL&cf@N^02JT3BM9nK8-8jPO0n#*entp>Q%b-ad z^U&89?C-+Wi?0w2GX**)n4BV-#SpGUTRwlYNQ$v9(y)n_s>k2P-E|A;6rhdO?K1p5qNE_pPlvA+#as|`e8zkCsib=&vlXR^83O%Eoz%Q|u zqWw=`p+5euRpKOTelBvY>6)VLC=XdWsB6L_ime}drK)pv<-V6I+l2np|Ceaf{29_` zfUis9=a(e3n|lbv;~>A!-y0<%o)3}ojWR7#O(>?>J57^Vrevz3{nfK=Q{ye~VIN=I zeS;l$@V1CxA8eyUI?>KRPjHYqYFBWTDstax2breF1QoygdzZ*dHYpWa$HsX4RPptJ zzT@c};&2Po%>1W+u^y?a4}a_L8G6VU$wrHm2&XoYY4U4yk&bIbt6-aiIg)qh5^b;` znMS4JRGl%FJoOC{@|`?wvt-&yh$mkcgN!ZwE@8p$8G4Fej^R{mQ0E*|uOOTg*ccpz@7y(=PT7 z=G9BUp9(dH*%_wzyKmrhb6Z6qABZ;evxD7@GTMb$#<=^JDE?-{q&RyeYX9sn*1O%ncjjTPdCbN&bZ8sP;j-b|_~*j1>j zRyEpo1t-v%d>Mb|{V(^RK_Qz&&aOp9)34&q8Ma4QNPEN!jf$s`yrWKE;ZN;CG0(l? zk&ZLeR&e{pl8nug{^_55e~5{%1pu-P8)Q2L@DEEqVI6)f`>C8`_~*eXjC4~kFTvsc z65|;5agK4d_!R2~sYttFMuY?R5cCXIK&m^#u>@V6|x%;$5b~b2vVTc*L#lMB^3AEEJULyJis!kPF$hNG9l62B1t9h1sv=@ z{2#K|`+Ynh-~^Ta@7dYI*T++!#Wheb)h^U76zRy_k9lYpYo2TycLyW$a}VbalwOWf z^ z-8^=X6L+`f^9&W@eWqHxV}n?|e7RQ=FwfldBG0too*C2^`IKd>`_Rs!YbL<^ODqz3? zR+?#x$T?BjXZF<(H;@W`j=P>@7slF zhHMl2_(qwm|EmB}JS?MVMqU8bZyy_0@oa+}Q)OyLgjjomz4KJHOFwi?(;DTwhrEDg z=r(_J3<m&?gAV9E7zYOZ`4z5sfh}|!6 zj@c|d)|z-eSKTANnU`kTBaVA4);QMr+vj0sKVSW9)o+WWy98{5sC!|~2iS#1Gc@Z| z=g7t>h}U8E4~S0wbML*wKu;6gig_VEg1rR0m*_L^Em9s3DFzJkm)NeL-8}vq-2qTf z#G2otG4XHiF3=g`1_0ns(slFHufD)Eu|(nHcZgXh?e9Iws9sDnr%{xx?;83D;T*n? z9dEOX3j%Co(~Su?qaDsrR!OQAq8;3W`*_%gk^lrZERU4Ys8el4v}Eb8$K54 zI{BUunq&bN8g=r2Pw;z4(WU`rt9X;NKd`9R(oGN0z0{$ut@3rUyZAd;)Jw#h0S*V~ z)T=)3LJb?VSMOS+7{-@~7$$;TH?Wj`K0-*>yz!ss>t>Fz007nEay6;?7>hhJ#<^yN zWFxhlL$m|z4+BTIMC|!}4tWzGL zc!v;vlk5#L7po4^cn7t7>S6Q_7H$C`y}dj6=eQk%{en)=P*23$3qHr$;QZq0d&A#H z&a!~I2LX+eFK~LvdgTMO)YBLbEVD^^m&hDF(+s|$H}EXI9#Qhu4?vHozq42!!7lpQ zyWL)X-tN!kuD;BpmniS9sVD9s(#-eK&rrvhIp(uJGYv_0Sbv58Q>EC;in@=tm+*zZ z-QUkP8GYvxPPQ@TOPsOtw>NzI=sB9$|IL7@6%TPBpDTVhP1P(!y72eLJcPe)kbI4G z!4BcJ*Qj+1Ss`N?qnk0zRH|ehrC3@ff0Z*%(<%^d6K=!bu2!I2G{_3@nxTvOa*kXt zbp!Wze;25cFI6?jN-=u|1ADfMbqO0|nW4)vcm{j3Kkw`;*}K2>zj4OL=6eYJv``1y z->ie^J8>>ZzhycETS->iKSbKlFAQ-=ez(dQWBV>$Y&J^0Oh3t}o3VmhBLfET_H~K@ zf2cN!KWUed%#QPXmy33yTEyJ%<8Y4nE-zh6wu-$^wH4>WGSVbC$kL)fvfRf(xOfit z#(z}Y_lrUq0B{fPkfiAfw1ETRE;v{E$tOqwL0KB(>}e)u@d1A|hrRlUU>S$<@*Bq} zd5#hFL_H1qnWJ}-764dBkMKC>!@mr3KHhi-)2zTB693}uHOkql>C+f?cg>@zFK0ONwd%; zjBB7%0PUns+B1-I5cXE0ahUzJ7IqGA6Yk^LB1o`8Im_3sm%2i*kI~DSV0yElV0)H< zZJgqFqYR6rKuyrGH7eNptK_X?FHme!4)BBR8J6U} zaZUaFuu9x2LAnlkJ-`6~LtIO2c^X7JQkA*pC*Vu$G9}}@_m7f2?48?0n)!`_Kh!(| z&5VGR!KI(W%H+CtiU&c8VL~ zpQP>LE>?R4d%w~kL%C#;h<1*3^zJ~o#WcYyVheAKi+Wf)>o0>+-^eyWKM!9YrAmS3 z@7k9zgv+1Hx8L)(9pi`jvW_6YXjLK(K{=u}#X`c?yGkuK8^X7ymb3lUSRV?=76dcmJRk$sQij z>U;1BMiB4^2I-M?1nKrS)*!ud(*jl2XAl1sEC2)YyhzW-FU-{}QT1mR^AxLA8O_8n zx4&D1B;7F5W2l2s+N=I&Xy2^3LC8GLI?6bSdP=wnbN2`_#&Us#zwH%}t*?{k7V_HD zy@M&#?B@3fGEP_gPB)WaWtQ?f{RIFCmQa5PHyxs<82&&7dC<(Y3pGmofjUN)uc4a( zdv=VxLh$w%=psXiPD?hi(y-s{bMP_Vy>ks#p>F9sr0pRI8uhA0R-FEyCc} z%?jCi#wn8wA?`Jb&yas0#A^Lrd*%9Bvdv#E6sibzaKg8O> zNL#+{1FUsY%2~CtQF`tf<=i@1$yTL&i?m6GDrJclikS_Pk3TQLvdsLw86a6GPQBGJe{YAw=i!vK=ui+moA|I&?;t~n`a)xkoXMSRs(9g?^mnrd*g{P;51eAy{dWpQ9q3djW{HGOfQUeXhmbkZ9Z^s+B7J z$llk?zC+E|fm&Ue8q>qdb5AAny+t~ z3CU`h%K?^PM}y=!vRHGy6z!~KV%G=O(PQ)>4$w=gX|%%tmq6zOB;{h3!5&W4N0z^t z3&G|esBfR69RxZ}|7NTjMabth@-jbh_U33zQ_+uaVb_V{ZQO$|kb*o!TfTiB;L0+H zwWFVIlz5B(E%FMv*IN7@>K49N*eSeU1Z#)6+b5R0uTWx$ANLyJl)LfWv2vwl@(Gr7 zRgN8Bn|zs00o7`eicnppvcEe36lsvG{UT$Y6Kk+b&@E^dkZa@ZUq(+eGRnG#uU8VT z$KS6O|5%e@Z5m;jVU~e>s`Qh38gU2vn7`wdH~*kW!|P5sPwN+0DM_)Nt53E>vW~ya zIpf~hmrj}GK7%<6HqO-!7t{dfI z6$yP=scw>JnweoU!6093mvs;F_OAQ*a)o)sHS>P4hi9J3I76erCLa1ut&rr`EB*`< z`@sL+5S^76<`~o{VQtP5%U86@gF%&OIR@eWc8HPxNwHuP26ewfaEgg`9OQ{{EZxT1 zMYJVTKENIKw~FxdU8TBx(ym;j1VBWmTlf&SC7)RPO|qS%74u`AZxCVbJR{FhTmyZb znTKS4DmC%I516 z_~%mY{t|WJrfglsYSyv$E69JI?@cp#<(6@=H(n=qpr2YK zhZ#+Bo5UE$2=@w%J9ybT(skv#4pE)L`#uEt^zs}djWVNd11OnBY{P2Bv~yCW?DJx^ zwSVIt|F?6HGjyaY*HBSTvc*X@KEXEeRP%U;(MHAk zEE6Aw4U@6fr#LkVqh$E;VuEb5D-{r4Wh8fCLx;fd}OH8<%B$E-&U;zEp7&}jAM1V+ZA8!vY zm@({!ex-h zs~r$87^XY}f014wDSp3$!QS@_)XjB>{#f-4b`KqIbAdEN$36@Ib<(V(wsBEksulk3 zm`-c&?_geKeSMz6#G5n=Q%xfszkQ~g!QQ8tQ>bPgEmnW`o2Tyy+%;@44D1Nt#CE7SgzK8yMdch``zw)n>5gT|0$kxg}EmBo|LOrOEw@8e!;u^A!O>&TK zfcP`Yx=HjKOT4Z}G*f+pVui9s#WX3-O#BA`rP^?f zx(5$(Zeo8fIYf_jc-0}!evY|(Wxhc_2lbBT7edFO{~44Xiqen?QV4dzD8 zC$%(|)fgM&Sn?(QPNFTm6aB(*t~}Lsv_4*uX1pEN(H*?E699j!>jT*ee>Zo(W}#~+ z=;auldAwERW7SWYIhthiIU31^Gn4_YG?QH2OT<#ucpKHyMaq9uJosD3=ytJuJ+rh- z6HVeBf-7WL2b}`DxX9;&oJSaJBV;RTC9a{}{I8P&n}h~g+WBKFgPb~fr>Gpmc{;EE zP^eX=Nw9rD=-_{eyoO^`M78D=&EMF||9)+XT{SHzfM)gZU74a%W}K`F4z@oo}=sUCe-nZ zs!-*t-96X=9^JZd3&ai4D)0>VT*seg$}ssV>+7e~z%PombG-z@LFyOh2(7|k2l#WnqDZ?BgZD4>Qi#WM)aq3(p$G5KkFVhlVEDUV`*?ZU zxZBsry2T#eZSv9$Zf>(I!z{COK7qXB!VND^L)^*65q^d0HsR7Gaju6r>fb+?OMlnR zOEYW~*d+MZhxs9ElD0+g?sq@;76HlXA?7r_Zmv~A#V7RR9XzReA3w*)LrfpPcfUtj zHHsn~jnfM?DV9D~y><@YzFZxfc+}GjBeCW)6w_4x?jjA&!FHhtCzP{hvDcS@5l-Mw zNEc1w(sldTzP_-x+xY6mwX&9ph1zd*NTMYKutA%nXB^uumaX%t+AJMs%ioiGbe4u{ z^_zILD)UU718_UY*dmo_)WUO);GS-h&?4Bw^(tGgyg*O2p`N!uMYm#^N;tRq{uv}) zZ=RB>yN7C0D%wi7@cJ6@5p04UV|V>M=g1V(A%SL=yZaG(lDW6r#uUD$88zrhX6XV&<0tbZJFVTWMqaNe!2Dyv&IQZ7c z8I+Q2c?KXKa(8+AzSe&Lz|;4BQ>i@4oqi_VMZU({n`UO2Fy6r=N34%!2>b99sZ8k{ zJK8c=!_ARxdYnhFE7NM4($9;0u2Z!5^S^uGaHnbow37lY;`L?ns4qX{?qK3<9U}`h zl`2Qr#F|&gOjBR`dCg)`UownXN9|&%=b6XDHG_C>TblhFE6QxS^;=gM? zT_?`b8{m5Pn`Nw1AV;rE1?4Q!Qlb&_&@=G;+BW_utB>CRSE<^YG}Pn!onFD`7rL2j z^?6F%bJr-bk^%l?N1GV+(n^^TZifV(=1CTX3h~}4hAn*d4gdz}BJ2zD;j8{r%ot~_ zGOH!5lG052#nW{I-6k1}^o}r__$Tpk&(p0eqW5tQ&_X@vXXSoM^pG!jdcHeWDVMDj zXg$S`GJV4@)MydE!z$4ESOGk1q$A%*HJu@fH?+ua5!A|3jTh?5*F8W;HYC{9$eE<9 zkeMW5oFkkt4Ka65Eei;?O`xOujdxE#ARL|R#Yg?j_V6Z})Y>I!jOpsw> zA1hzu^tDOI&)qz&OfJSD#~_e~Wei zdqzK&uX*?T^S9Tz&DYv(o?4|W)^3rqUP`&VUP?Rvbz&&QjbjMygunZr{p+OuAA4^d zRrR*@jnm!T-QC^YAp!~#0#b@}BPETLNJSs5Kk28k3=;j`xE0t{!j58KIef4>YZk&;QkaRi1afJf!ZVm3gQ@liq8K%6pT_iM;Fdordgv% z_}c)#KtR71nFJH9+>d6EDAMh!`QA>PBkdBmQ2E-2Xx2%M5+c>e(5ru*!%TUFb12tgGq4W?_iKkw8ht%VPKsQZBIV^$<8bX?j&1Rxw4Es;iyyC~}#xij>4L8LQpKXDI+yBFzDF9_AY^NzNgrV(svmp(u~ z#Q4K2-8w}YX1YUX=$HF5iiv&<5BXCU@+8V)7el4FTxE(Z$ju}p!kMqn&y%~aRB4=6 zv9(@wgr0uN{?kWW!<1XZ=vQZ$_y_FWkBKPfur9t8k|YbB9)XT)Sg5-i8BniuOU96vS$dC=n zL2q%_2oCtWg`1YhStdW&9ip1X^@*$!Qf|J9whwm?DtJP&IL8Qi>Erc49qN*!mtxf> z8tp9HE!KRAcy}|!jJvH;(!tMHxj?b| zCqC=QcY{^J2=5v>q~kKhAX}AOTH25 zMmwYPo#)TnyRg>-T$jk4eQ^8N@a)q(-5T{++Yz?tJDp6^ywZ*K0p+UoVpQ8gU40ar zxDGMK$^IcTG#@Evw_Lea{DT|xZLEb?(3=Fq9Krfussi1v z!9oof>kKo?WO>i2CObtKCflX6HLm`AdQD7jm6z%F zkap2;KduoSVCdx4Nm0*|ErmOOdp^SA>)XW_;^rQFhBVF?W&d#h|6$xf&r(M^1bF>Y zO*K{hm13q@M7G2>G{_$JI>BleJJT@5EL*=pVu+1!J<@?>2EqoGx2!nMS?b#de9+!$CZ0^sSc1C0(q6t25Jd z1*2aWcUvpFP?upbPeHFx^-J8_23h8f$Nk4XlH~Y2a|2Pg_!RY3N{L3QxlU$)-vOas z?$d9k$$i2FsyC;vKMyetGG@Fwz(atLsXWJw)whXxnExt~S1<%T$|2{FXg9%1vGyK{ zVu?_NRL?0|Ge@~L<^1b}NSoN#M`(WUIVavZ8KmDsuhOgiJj8et{V4x`D@oQ*&&`%aTRVx9vcK%xbF-Dy{ z!%VRzl~T(@=-cnlzCW`{0RJ0hw~I|PlV};~utGvLcZS3|ELHbQRkH{J0_DWp{~zbT zTH3^1Vq62ZDN5ANpvyGou@mk6{hxf*Dd6r_D;?tL7KyeHZv1S%LhKZ#Uc@!BM+&(k z)nu6dh`vnn(f%F*8Wv-(n@hIlx4N}A^R#}_CGNMEL)^j*enAX_5ZBN!6!X?$@Rx0h zZ^K4-zdb*IzK6T|lRm}{`Bwc$n5(Pb5&j^DRtd@cFO?ItS{atUYU$T0yyJ+wVQ;7D z58)*0yLnwcVePRE*(9Q^A)P+v(K-9?VCLuzvSwH$YVuw%jMhr-A{-FV&QvJLm7rau zz2I$SoG&y&TZ?mWh~L40KboV&+2QJ2Avyn(KFme2#Un(p7JDtsE8j$-s7ij7tU?(P z0{V)rXM*}wid_A(tSutN-w=0oYKfNL4b-yGu6yaA9@BUbTmx;A79ab?hk2^RokEzW zEYiHa#j1WNLEm%>33qSenWt!HuTZ>+nc_-+@#RITwQQ}ApKuFH*CT!p_fPdOmq5?Y zufD#Rq0uOYzf}H>e5zSAL!*}?(!$sE;w$a6LWNk<9#)mylOLP-wbEuOFTP^#KjNPv zRm&P?inN>}L*H5?W$P=JOV;aWZ{q9a7-TVzI)_uwMmW|>diy6?I7Cjf4VH|9Jj2a+yZiPrZXzNui<6k#RSwl{B)g(hksyR1ESIYn=jC(Rw94BfFS< z-i5hGxyCq5kYVm#V?NgZN;f4t_@OA)!PoQIn0V%+?H-;)<7bO6<~7VLOB5TU+%a!X zkg->~1>zi|O_m7!B9=)`P&is~xBhXDA>QUwy2Utkl7;Jsa&_3dSO-rh&1$Juu}agl ze)cS_vZsX#RQr0VVg5s`IZw?}em$)g$N58>J?=lJ578d@2g!zH(_1t)2}jUD_M3!B zmRhBh8xDcABis#6pXHi#zgJ6X6c;P36E-Ok&$aS)^Bkc+&VQBIHVz7Uje@5;#Q-V=l?tB#lh`odW+Cj88`wLT=Sk+75iXDNN26$t>bLSZ z;|$HGHx~%LzN^GP)vM%YsiE$Y&09nSI#5p7hHhZa5y=-D#b;=A@-htv*@atMh5zFJ zHA1wbcENc{kVn|Ym8+_e(Jb-|EKzG0)Xs;$L_D{N_3;~JhJ=u;qnb1RGQogwfqI-~ z@<=b$ILVE()z5DgiNA3MWuNfg59!$O^91u4U%4dJoMZSEG{KT;fVPLfWfkX;gtw(o|H%je3VTDm8*Rhu zeW2&FuWVDHPB_c^q`g83#_T<|;rIW*U!=e|GEGshh;g8u9b-4h%vNubA==nPw~pf- z#oQF>3$xoIQu$RScYw<aku9+t4zpX3;%lom8W^YiP3hC^N~5O{`4KIihPQ#ljwzNDIOR$%N^-JfoqGZ-afpvP}2!wvkscKG}TN zQEO!EGW@YaxJ-e%9%ojp3h^KCb6&vSunuRwRw!#0)XVS)iE=JdrI~s5HO9^;4H~gp zT(eRu&oSm*Fv)xy{}ocE?jBZ+>dv3k2N-pV>LqyV^%8@k`pNCmL9RE5Gz01t#hNje z7wFM8zVE%?yvc8;c5Kr5-E8`I&%SAFS~=wFpRS~ zMK4zt7@TgT{k4aUcGl`kjCHilCz~p1&8iV@y!m zfN&wvU>o!5vqciH87Jv!X2M)*rRAyyI9$Ro4hGpjy%}eet_|>_oBFH%-v#UnnN}_8 zNr1C!*gncNLaZa+s(4evn@pWzEsF%xoR<-lv*zDIyk)8whbfmYP|KxVB2|h`pzr?! zexjvYm`y^Zd{7|5MVkKIy?LxaccoUiN0|cU^a$hAY|9il=r_^Qm5W5@Cb|Mg?sVzr5YYV@eRS= zP|eNI@8Em+E#qGPNn56HjU>^)KIY?aiLpr#<5v8PXhkP2(H!;W5Wb%89y-B^YvdBn zEXg+6*Dc1XRZg}v!lqC&=lOqp{wODg*`IAL5l%60F8F(h52lHW72H1Q=O?^eARnO? z9DzB}{ic@NBOU2*ewXNYaclKyjOP)*LO#Q2fkLH}U}KF4<%DVMb=)}PG_9Wx$3U>V zeN;E^(}FF$I;lv97Lf)Cu_lwWO8IbS_{(F||A-F(Q7vN=OR@s028Oz-{?aV^;B*PE zo2#AA-78ZQZFhurf&%V>VH}vI?%=EurI|eB9>9onELAuD;_Izd#W2_)Au^Eh!Xel$ zQnJf7uuBwwV~DMvqd^oBBGg-;1>&-cdyZMGwN^O5m3a9-!T+%XN;J1|$TmF#rs&)v zSVv9}Gpu0ln*_f;wfPw9ILs*8h`o(?xLgkS^!!`Oo-->*OoN zdIe9`iT&J*)!ZYWr^na@`YM+9QC{gbXZ`4ZarZUL z*2>k(f7ke?Ya5bl3JZz1*3EGSU!t>&8DVpT*CU}&hIkWee2zi5_B@ASnqWEI+||p= zAL>8h_j5lh;T?GIVw`OK(KdOK{oQ-fIvBW{t0&((Bm#ZrNe>aslU5m_jTgQKUK23XpP$yL*lVAmRMY{YleS*QyCtH6LAAj@xJC7iq zo-V#EyeB{Y>-s0ZO0r;>*u)=Ywn`9fHcPQg^!DGu`S>>8+9uX9Lb|qHP^85(P_F97 zlkd;WlZn>!a{ggIqDFY_;JAkJ^u*X(#p&i1Y91qoSYF|%R~IV>`-a+0QKh|&a+;xZ zi41=mVzY;Qj{tLSkyQNTKR^FXyic}Qh+1D8gmVmA6?pnD5d+>qL93NqLzO6>qh=e_ zh$I-1ZwI-tj&?Ik_wsfbWIxtFJ4V03n8z(sVVvpU)u@!KY2sJNwJbV9Q2yoP6Yg3s z(Zvo0_bMHBzg;xieSoE(bDZM_BJl<8#($oFqj-n#1x73P1oKa2%%h-yc{0kGW{GS) z{dA0Fr^E$vnf888>H0}J#oymF?2`iAPca_%U!1j8Nu{D*&Z`u`;VBvq-?w4LSyeJ{ zaOR)*nm-w}N|BrmGTB9$MKjI44=zx`--LwvZVYj}Nc^AiWvbB+^uB+#SfCrH8l-&l zHOE3Hhjb~)&M?E*KE%6CVi!d(YZcEr$t$#2r$L-z(lYgN{i|fDXK$fNm&X}>{IGWw z%lEMUV1fwspSroRHi?!)Y(^P@p0(2TlH9%5utRLXLfpkxD*Njk@8hVGeD{8iD$}r& zznj+~Qo0uXkYK|-81f#RE9dLl#OLp

8p_hAq^pmi_*$M&^;8Zt@TRJF}PPSs}&& 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∋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

%5(%0 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

JPa+o4;y1;494sS8HXQO2Q~VVr0b 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

aL94}%;mj|lnC&q&3pv8g!KXs~t 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

y* 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

3auTu&QapR|R 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 ^!2zB!Ewf_qJQ!4liV7Ksf z8)f_wAz5vdJixk!@8iCMC0P|}yFw8E33u~Lr0c^xRk1qj$Thrd?LKxd57BzFm}DdE zj9$(+@5nC>;b^M`X(_i5` zhVA0gPbFAR(+Rc@a=d{KvFqgkd7NOcmI*Yo0q%aD&0+!_F!z8)C((M>2fL^!JAmg4 zV0rZM5o@lKiT&*9C;k(#$p8)?ibc#r#)ZLNEfZb zO%geJxVv^y1zIL4cd!eT0P7u4_jHWj!+VISlb2=S6q;d_WcCEfI#MlPFFi$1H~ruE zo_<5@GIbS7fW38vFhy?{^#V?|e1azO>tp&QBH>1zj9yNYgk4mf4C*QM9P?PRsZ;~a z>=gppvQ~jo#Sz8}xJuax8ueVEChJJ#7mTArOpT&sQ?-&98=*FT589bU3P3YSJ0Eu! zsL1+ne6nRP-!R8D;#aUA7Hh;x74uYODkx{I!ur{}xY_#85Into+yz=cEEXxG>)itJ zcHpnG44xq*8^xM|`0nO=>3Sdrhj_X9o@Er~o@ySb-FbJ5zfC(sw2p8E_yN>&NEc+w zNEd(D$$vEh=%!!5uMj8~sONt3SbtmZd3&HAVDC5v0UMfgfWH�MLhncqZ6lA7Y$9 zJE59?17(~5b{dXBhDof0Yj}pq4gtyqyqzrqUoX%%-ku%2dzb-Ei03AWOhc}IgDi&0 zXj{FU|3v2L9ptc%V;ioK;}{Hc40AM2JHQHY@&2Y*iFI&*^?vh*MXBl@jzQKU1@r^K z)(9)?9f0#OeS#6-w&U+yCxX4(#@EX^L*eT8@xeMMQ?dAAl!3kfesh9xik^547&#|s z#%U4uzeGI!{(JvtobdHRJ#CU`7W49@n{JRoJ0aXyCbN$yRxi|~n^rIG=Bt%`fMy>8 ze*vm$b#r@pxcegPKXh>n;_miyA)m+C5Nu%`EKtJS-@a>-;OqAAZ;;B>)z27>2v+5;Dn85YsunsVe=%%3_ z$(Dhb0n!EgkU$4_A5d4oIzqAv_!Y?Kv@>`+G_yz-fOP>_pV<5O+ejB)zG=o8MnJw9 z^7$KRhrkbu3C5tW>cx>?u=k-KqHWQS{~g;l+A*wB5r|Wvo|-4Z-ON)tgx|o8vMiCi z2LQjtTLJI!1Ub$`yrx)5vDhwHFMSQq)jvnATG}Mx9sv5*EY>IqR6Wbplq-Y1*2+FZ zXcqc;BA-7&lB@zXOj!oaV%PA{4^ehg^z&4Au>ZO560gDCq<;F8Y9Dcqyo<}*BiXn{ z`~)fTE6NUMkGCgJ2mA%|5bh?;80D-*^c4*5W{f%139w|`0|eU*GrtD?5}Be0ydULq zlN9G}*Kibb3~z$&d$u#4jA|IGxW15jLp>!n#oBpd%7-z3F2%_?3eZ<4_^_y8+k z+dd-6jCN*_gKiq;9`eaHTA?bzl6*;^L#D1#@RJQ&bD1Rs5(~q#q)?UNI+_Q}K zao@mg5!}P%>avWIEprYqjWbT*?J!OtT~IFY^pY>l(ySA~-B2uY4zP^=?kE0n3$_C> z0?q;CbBv=GaF$Wza}W@QNs?8{1=Lg45sF3pZPpQvLFzfKe)b{mKEQ(n_7Cnps7J(0 z))De0(iPqwAX)�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