diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 0e5104fea9..6da0ba3dcb 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -202,13 +202,10 @@ def reload_pipeline(*args): avalon.api.uninstall() for module in ( - "avalon.io", - "avalon.lib", - "avalon.pipeline", - "avalon.tools.creator.app", - "avalon.tools.manager.app", - "avalon.api", - "avalon.tools", + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.api", ): module = importlib.import_module(module) importlib.reload(module) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index 9dd8a351e4..ca7efb9136 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -5,11 +5,12 @@ import logging # Pipeline imports import avalon.api -from avalon import io, pipeline +from avalon import io from openpype.lib import version_up from openpype.hosts.fusion import api from openpype.hosts.fusion.api import lib +from openpype.lib.avalon_context import get_workdir_from_session log = logging.getLogger("Update Slap Comp") @@ -44,16 +45,6 @@ def _format_version_folder(folder): return version_folder -def _get_work_folder(session): - """Convenience function to get the work folder path of the current asset""" - - # Get new filename, create path based on asset and work template - template_work = self._project["config"]["template"]["work"] - work_path = pipeline._format_work_template(template_work, session) - - return os.path.normpath(work_path) - - def _get_fusion_instance(): fusion = getattr(sys.modules["__main__"], "fusion", None) if fusion is None: @@ -72,7 +63,7 @@ def _format_filepath(session): asset = session["AVALON_ASSET"] # Save updated slap comp - work_path = _get_work_folder(session) + work_path = get_workdir_from_session(session) walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") slapcomp_dir = os.path.abspath(walk_to_dir) @@ -112,7 +103,7 @@ def _update_savers(comp, session): None """ - new_work = _get_work_folder(session) + new_work = get_workdir_from_session(session) renders = os.path.join(new_work, "renders") version_folder = _format_version_folder(renders) renders_version = os.path.join(renders, version_folder) diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/utility_scripts/switch_ui.py index fe324d9a41..afb39f7041 100644 --- a/openpype/hosts/fusion/utility_scripts/switch_ui.py +++ b/openpype/hosts/fusion/utility_scripts/switch_ui.py @@ -5,11 +5,12 @@ import logging from Qt import QtWidgets, QtCore import avalon.api -from avalon import io, pipeline +from avalon import io from avalon.vendor import qtawesome as qta from openpype import style from openpype.hosts.fusion import api +from openpype.lib.avalon_context import get_workdir_from_session log = logging.getLogger("Fusion Switch Shot") @@ -123,7 +124,7 @@ class App(QtWidgets.QWidget): def _on_open_from_dir(self): - start_dir = self._get_context_directory() + start_dir = get_workdir_from_session() comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Choose comp", start_dir) @@ -157,17 +158,6 @@ class App(QtWidgets.QWidget): import colorbleed.scripts.fusion_switch_shot as switch_shot switch_shot.switch(asset_name=asset, filepath=file_name, new=True) - def _get_context_directory(self): - - project = io.find_one({"type": "project", - "name": avalon.api.Session["AVALON_PROJECT"]}, - projection={"config": True}) - - template = project["config"]["template"]["work"] - dir = pipeline._format_work_template(template, avalon.api.Session) - - return dir - def collect_slap_comps(self, directory): items = glob.glob("{}/*.comp".format(directory)) return items diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 134f670dc4..66eeac1e3a 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -361,7 +361,7 @@ def zip_and_move(source, destination): log.debug(f"Saved '{source}' to '{destination}'") -def show(module_name): +def show(tool_name): """Call show on "module_name". This allows to make a QApplication ahead of time and always "exec_" to @@ -375,13 +375,6 @@ def show(module_name): # requests to be received properly. time.sleep(1) - # Get tool name from module name - # TODO this is for backwards compatibility not sure if `TB_sceneOpened.js` - # is automatically updated. - # Previous javascript sent 'module_name' which contained whole tool import - # string e.g. "avalon.tools.workfiles" now it should be only "workfiles" - tool_name = module_name.split(".")[-1] - kwargs = {} if tool_name == "loader": kwargs["use_context"] = True diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index c774afcc12..a1e0be2cfe 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -37,17 +37,17 @@ class ToolWindows: def edit_shader_definitions(): - from avalon.tools import lib from Qt import QtWidgets from openpype.hosts.maya.api.shader_definition_editor import ( ShaderDefinitionsEditor ) + from openpype.tools.utils import qt_app_context top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") - with lib.application(): + with qt_app_context(): window = ToolWindows.get_window("shader_definition_editor") if not window: window = ShaderDefinitionsEditor(parent=main_window) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index b1934c757d..5f0fc39bf3 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -36,7 +36,7 @@ def install(): return def deferred(): - from avalon.tools import publish + pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( MENU_NAME, @@ -80,7 +80,7 @@ def install(): command=lambda *args: host_tools.show_publish( parent=parent_widget ), - image=publish.ICON + image=pyblish_icon ) cmds.menuItem( diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 2e85514938..dfe2b85edc 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,5 +1,6 @@ +# -*- coding: utf-8 -*- import os -import maya.cmds as cmds +import maya.cmds as cmds # noqa from avalon import api from openpype.api import get_project_settings from openpype.hosts.maya.api.lib import ( @@ -42,20 +43,20 @@ class VRaySceneLoader(api.Loader): with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): - nodes, group_node = self.create_vray_scene(name, - filename=self.fname) + nodes, root_node = self.create_vray_scene(name, + filename=self.fname) self[:] = nodes if not nodes: return # colour the group node - presets = get_project_settings(os.environ['AVALON_PROJECT']) - colors = presets['maya']['load']['colors'] + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: - cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) - cmds.setAttr("{0}.outlinerColor".format(group_node), + cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1) + cmds.setAttr("{0}.outlinerColor".format(root_node), (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) @@ -123,17 +124,21 @@ class VRaySceneLoader(api.Loader): mesh_node_name = "VRayScene_{}".format(name) trans = cmds.createNode( - "transform", name="{}".format(mesh_node_name)) - mesh = cmds.createNode( - "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) + "transform", name=mesh_node_name) vray_scene = cmds.createNode( "VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans) + mesh = cmds.createNode( + "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) cmds.connectAttr( "{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh)) cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string") + # Lock the shape nodes so the user cannot delete these + cmds.lockNode(mesh, lock=True) + cmds.lockNode(vray_scene, lock=True) + # Create important connections cmds.connectAttr("time1.outTime", "{0}.inputTime".format(trans)) @@ -141,11 +146,9 @@ class VRaySceneLoader(api.Loader): # Connect mesh to initialShadingGroup cmds.sets([mesh], forceElement="initialShadingGroup") - group_node = cmds.group(empty=True, name="{}_GRP".format(name)) - cmds.parent(trans, group_node) - nodes = [trans, vray_scene, mesh, group_node] + nodes = [trans, vray_scene, mesh] # Fix: Force refresh so the mesh shows correctly after creation cmds.refresh() - return nodes, group_node + return nodes, trans diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6faf6cd108..dba7ec1b85 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,6 +1,5 @@ import os import re -import sys import six import platform import contextlib @@ -679,10 +678,10 @@ def get_render_path(node): } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + host_name = os.environ.get("AVALON_APP") - application = lib.get_application(os.environ["AVALON_APP_NAME"]) data.update({ - "application": application, + "app": host_name, "nuke_imageio_writes": nuke_imageio_writes }) @@ -805,18 +804,14 @@ def create_write_node(name, data, input=None, prenodes=None, ''' imageio_writes = get_created_node_imageio_setting(**data) - app_manager = ApplicationManager() - app_name = os.environ.get("AVALON_APP_NAME") - if app_name: - app = app_manager.applications.get(app_name) - for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] + host_name = os.environ.get("AVALON_APP") try: data.update({ - "app": app.host_name, + "app": host_name, "imageio_writes": imageio_writes, "representation": representation, }) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index fd754203d4..3e61caedf9 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -446,6 +446,8 @@ class ExporterReviewMov(ExporterReview): return path def generate_mov(self, farm=False, **kwargs): + reformat_node_add = kwargs["reformat_node_add"] + reformat_node_config = kwargs["reformat_node_config"] bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] @@ -483,6 +485,30 @@ class ExporterReviewMov(ExporterReview): self.previous_node = r_node self.log.debug("Read... `{}`".format(self._temp_nodes[subset])) + # add reformat node + if reformat_node_add: + # append reformated tag + add_tags.append("reformated") + + rf_node = nuke.createNode("Reformat") + for kn_conf in reformat_node_config: + _type = kn_conf["type"] + k_name = str(kn_conf["name"]) + k_value = kn_conf["value"] + + # to remove unicode as nuke doesn't like it + if _type == "string": + k_value = str(kn_conf["value"]) + + rf_node[k_name].setValue(k_value) + + # connect + rf_node.setInput(0, self.previous_node) + self._temp_nodes[subset].append(rf_node) + self.previous_node = rf_node + self.log.debug( + "Reformat... `{}`".format(self._temp_nodes[subset])) + # only create colorspace baking if toggled on if bake_viewer_process: if bake_viewer_input_process_node: 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 5bbc88266a..1071834497 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -1,4 +1,5 @@ import os +import re import pyblish.api import openpype from openpype.hosts.nuke.api import plugin @@ -25,6 +26,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): def process(self, instance): families = instance.data["families"] task_type = instance.context.data["taskType"] + subset = instance.data["subset"] self.log.info("Creating staging dir...") if "representations" not in instance.data: @@ -46,6 +48,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] + f_subsets = o_data["filter"]["sebsets"] # test if family found in context test_families = any([ @@ -69,11 +72,25 @@ class ExtractReviewDataMov(openpype.api.Extractor): bool(not f_task_types) ]) + # test subsets from filter + test_subsets = any([ + # check if any of subset filter inputs + # converted to regex patern is not found in subset + # we keep strict case sensitivity + bool(next(( + s for s in f_subsets + if re.search(re.compile(s), subset) + ), None)), + # but if no subsets were set then make this acuntable too + bool(not f_subsets) + ]) + # we need all filters to be positive for this # preset to be activated test_all = all([ test_families, - test_task_types + test_task_types, + test_subsets ]) # if it is not positive then skip this preset @@ -120,6 +137,13 @@ class ExtractReviewDataMov(openpype.api.Extractor): if generated_repres: # assign to representations instance.data["representations"] += generated_repres + else: + instance.data["families"].remove("review") + self.log.info(( + "Removing `review` from families. " + "Not available baking profile." + )) + self.log.debug(instance.data["families"]) self.log.debug( "_ representations: {}".format( diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index 596a8ccfd2..ea0b6cdf41 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -81,14 +81,10 @@ class CollectTextures(pyblish.api.ContextPlugin): parsed_subset = instance.data["subset"].replace( instance.data["family"], '') - fill_pairs = { + explicit_data = { "subset": parsed_subset } - fill_pairs = prepare_template_data(fill_pairs) - workfile_subset = format_template_with_optional_keys( - fill_pairs, self.workfile_subset_template) - processed_instance = False for repre in instance.data["representations"]: ext = repre["ext"].replace('.', '') @@ -102,6 +98,21 @@ class CollectTextures(pyblish.api.ContextPlugin): if ext in self.main_workfile_extensions or \ ext in self.other_workfile_extensions: + formatting_data = self._get_parsed_groups( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + self.log.info("Parsed groups from workfile " + "name '{}': {}".format(repre_file, + formatting_data)) + + formatting_data.update(explicit_data) + fill_pairs = prepare_template_data(formatting_data) + workfile_subset = format_template_with_optional_keys( + fill_pairs, self.workfile_subset_template) + asset_build = self._get_asset_build( repre_file, self.input_naming_patterns["workfile"], @@ -148,11 +159,23 @@ class CollectTextures(pyblish.api.ContextPlugin): resource_files[workfile_subset].append(item) if ext in self.texture_extensions: + formatting_data = self._get_parsed_groups( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + self.log.info("Parsed groups from texture " + "name '{}': {}".format(repre_file, + formatting_data)) + c_space = self._get_color_space( repre_file, self.color_space ) + # optional value channel = self._get_channel_name( repre_file, self.input_naming_patterns["textures"], @@ -160,6 +183,7 @@ class CollectTextures(pyblish.api.ContextPlugin): self.color_space ) + # optional value shader = self._get_shader_name( repre_file, self.input_naming_patterns["textures"], @@ -167,13 +191,15 @@ class CollectTextures(pyblish.api.ContextPlugin): self.color_space ) - formatting_data = { + explicit_data = { "color_space": c_space or '', # None throws exception "channel": channel or '', "shader": shader or '', "subset": parsed_subset or '' } + formatting_data.update(explicit_data) + fill_pairs = prepare_template_data(formatting_data) subset = format_template_with_optional_keys( fill_pairs, self.texture_subset_template) @@ -243,6 +269,13 @@ class CollectTextures(pyblish.api.ContextPlugin): for asset_build, version, subset, family in asset_builds: if not main_version: main_version = version + + try: + version_int = int(version or main_version or 1) + except ValueError: + self.log.error("Parsed version {} is not " + "an number".format(version)) + new_instance = context.create_instance(subset) new_instance.data.update( { @@ -251,7 +284,7 @@ class CollectTextures(pyblish.api.ContextPlugin): "label": subset, "name": subset, "family": family, - "version": int(version or main_version or 1), + "version": version_int, "asset_build": asset_build # remove in validator } ) @@ -320,13 +353,14 @@ class CollectTextures(pyblish.api.ContextPlugin): """ asset_name = "NOT_AVAIL" - return self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'asset') or asset_name + return (self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'asset') or + asset_name) def _get_version(self, name, input_naming_patterns, input_naming_groups, color_spaces): - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'version') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'version') if found: return found.replace('v', '') @@ -336,8 +370,8 @@ class CollectTextures(pyblish.api.ContextPlugin): def _get_udim(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Parses from 'name' udim value.""" - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'udim') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'udim') if found: return found @@ -375,12 +409,15 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'shader') - if found: - return found + found = None + try: + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, + 'shader') + except ValueError: + self.log.warning("Didn't find shader in {}".format(name)) - self.log.warning("Didn't find shader in {}".format(name)) + return found def _get_channel_name(self, name, input_naming_patterns, input_naming_groups, color_spaces): @@ -389,15 +426,18 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'channel') - if found: - return found + found = None + try: + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, + 'channel') + except ValueError: + self.log.warning("Didn't find channel in {}".format(name)) - self.log.warning("Didn't find channel in {}".format(name)) + return found - def _parse(self, name, input_naming_patterns, input_naming_groups, - color_spaces, key): + def _parse_key(self, name, input_naming_patterns, input_naming_groups, + color_spaces, key): """Universal way to parse 'name' with configurable regex groups. Args: @@ -411,23 +451,47 @@ class CollectTextures(pyblish.api.ContextPlugin): Raises: ValueError - if broken 'input_naming_groups' """ + parsed_groups = self._get_parsed_groups(name, + input_naming_patterns, + input_naming_groups, + color_spaces) + + try: + parsed_value = parsed_groups[key] + return parsed_value + except (IndexError, KeyError): + msg = ("'Textures group positions' must " + + "have '{}' key".format(key)) + raise ValueError(msg) + + def _get_parsed_groups(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Universal way to parse 'name' with configurable regex groups. + + Args: + name (str): workfile name or texture name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + + Returns: + (dict) {group_name:parsed_value} + """ for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) regex_result = re.findall(pattern, name) if regex_result: - idx = list(input_naming_groups).index(key) - if idx < 0: - msg = "input_naming_groups must " +\ - "have '{}' key".format(key) - raise ValueError(msg) + if len(regex_result[0]) == len(input_naming_groups): + return dict(zip(input_naming_groups, regex_result[0])) + else: + self.log.warning("No of parsed groups doesn't match " + "no of group labels") - try: - parsed_value = regex_result[0][idx] - return parsed_value - except IndexError: - self.log.warning("Wrong index, probably " - "wrong name {}".format(name)) + raise ValueError("Name '{}' cannot be parsed by any " + "'{}' patterns".format(name, input_naming_patterns)) def _update_representations(self, upd_representations): """Frames dont have sense for textures, add collected udims instead.""" diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3ce205c499..0bfd3f6de0 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -644,6 +644,166 @@ def get_workdir( ) +def template_data_from_session(session=None): + """ Return dictionary with template from session keys. + + Args: + session (dict, Optional): The Session to use. If not provided use the + currently active global Session. + Returns: + dict: All available data from session. + """ + from avalon import io + import avalon.api + + if session is None: + session = avalon.api.Session + + project_name = session["AVALON_PROJECT"] + project_doc = io._database[project_name].find_one({"type": "project"}) + asset_doc = io._database[project_name].find_one({ + "type": "asset", + "name": session["AVALON_ASSET"] + }) + task_name = session["AVALON_TASK"] + host_name = session["AVALON_APP"] + return get_workdir_data(project_doc, asset_doc, task_name, host_name) + + +def compute_session_changes( + session, task=None, asset=None, app=None, template_key=None +): + """Compute the changes for a Session object on asset, task or app switch + + This does *NOT* update the Session object, but returns the changes + required for a valid update of the Session. + + Args: + session (dict): The initial session to compute changes to. + This is required for computing the full Work Directory, as that + also depends on the values that haven't changed. + task (str, Optional): Name of task to switch to. + asset (str or dict, Optional): Name of asset to switch to. + You can also directly provide the Asset dictionary as returned + from the database to avoid an additional query. (optimization) + app (str, Optional): Name of app to switch to. + + Returns: + dict: The required changes in the Session dictionary. + + """ + changes = dict() + + # If no changes, return directly + if not any([task, asset, app]): + return changes + + # Get asset document and asset + asset_document = None + asset_tasks = None + if isinstance(asset, dict): + # Assume asset database document + asset_document = asset + asset_tasks = asset_document.get("data", {}).get("tasks") + asset = asset["name"] + + if not asset_document or not asset_tasks: + from avalon import io + + # Assume asset name + asset_document = io.find_one( + { + "name": asset, + "type": "asset" + }, + {"data.tasks": True} + ) + assert asset_document, "Asset must exist" + + # Detect any changes compared session + mapping = { + "AVALON_ASSET": asset, + "AVALON_TASK": task, + "AVALON_APP": app, + } + changes = { + key: value + for key, value in mapping.items() + if value and value != session.get(key) + } + if not changes: + return changes + + # Compute work directory (with the temporary changed session so far) + _session = session.copy() + _session.update(changes) + + changes["AVALON_WORKDIR"] = get_workdir_from_session(_session) + + return changes + + +def get_workdir_from_session(session=None, template_key=None): + import avalon.api + + if session is None: + session = avalon.api.Session + project_name = session["AVALON_PROJECT"] + host_name = session["AVALON_APP"] + anatomy = Anatomy(project_name) + template_data = template_data_from_session(session) + anatomy_filled = anatomy.format(template_data) + + if not template_key: + task_type = template_data["task"]["type"] + template_key = get_workfile_template_key( + task_type, + host_name, + project_name=project_name + ) + return anatomy_filled[template_key]["folder"] + + +def update_current_task(task=None, asset=None, app=None, template_key=None): + """Update active Session to a new task work area. + + This updates the live Session to a different `asset`, `task` or `app`. + + Args: + task (str): The task to set. + asset (str): The asset to set. + app (str): The app to set. + + Returns: + dict: The changed key, values in the current Session. + + """ + import avalon.api + from avalon.pipeline import emit + + changes = compute_session_changes( + avalon.api.Session, + task=task, + asset=asset, + app=app, + template_key=template_key + ) + + # Update the Session and environments. Pop from environments all keys with + # value set to None. + for key, value in changes.items(): + avalon.api.Session[key] = value + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + # Emit session change + emit("taskChanged", changes.copy()) + + return changes + + @with_avalon def get_workfile_doc(asset_id, task_name, filename, dbcon=None): """Return workfile document for entered context. @@ -952,7 +1112,7 @@ class BuildWorkfile: Returns: (dict): preset per entered task name """ - host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] + host_name = os.environ["AVALON_APP"] project_settings = get_project_settings( avalon.io.Session["AVALON_PROJECT"] ) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ee137a2ee3..82c2494e7a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import os import tempfile -import time +from datetime import datetime import subprocess import json import platform +import uuid from Deadline.Scripting import RepositoryUtils, FileUtils @@ -36,9 +37,11 @@ def inject_openpype_environment(deadlinePlugin): print("--- OpenPype executable: {}".format(openpype_app)) # tempfile.TemporaryFile cannot be used because of locking - export_url = os.path.join(tempfile.gettempdir(), - time.strftime('%Y%m%d%H%M%S'), - 'env.json') # add HHMMSS + delete later + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 9f85000dbb..eea6436b53 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -20,11 +20,16 @@ from openpype_modules.ftrack.lib import ( query_custom_attributes, CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, + FPS_KEYS, avalon_sync, BaseEvent ) +from openpype_modules.ftrack.lib.avalon_sync import ( + convert_to_fps, + InvalidFpsValue +) from openpype.lib import CURRENT_DOC_SCHEMAS @@ -1149,12 +1154,31 @@ class SyncToAvalonEvent(BaseEvent): "description": ftrack_ent["description"] } } + invalid_fps_items = [] cust_attrs = self.get_cust_attr_values(ftrack_ent) for key, val in cust_attrs.items(): if key.startswith("avalon_"): continue + + if key in FPS_KEYS: + try: + val = convert_to_fps(val) + except InvalidFpsValue: + invalid_fps_items.append((ftrack_ent["id"], val)) + continue + final_entity["data"][key] = val + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + _mongo_id_str = cust_attrs.get(CUST_ATTR_ID_KEY) if _mongo_id_str: try: @@ -2155,11 +2179,19 @@ class SyncToAvalonEvent(BaseEvent): ) convert_types_by_id[attr_id] = convert_type + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + entities_dict[ftrack_project_id]["hier_attrs"][key] = ( attr["default"] ) # PREPARE DATA BEFORE THIS + invalid_fps_items = [] avalon_hier = [] for item in values: value = item["value"] @@ -2173,8 +2205,25 @@ class SyncToAvalonEvent(BaseEvent): if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue entities_dict[entity_id]["hier_attrs"][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs project_values = {} for key, value in ( diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index cb5b88ad50..88dc8213bd 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -11,6 +11,7 @@ from openpype_modules.ftrack.lib import ( CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT, + FPS_KEYS, default_custom_attributes_definition, app_definitions_from_app_manager, @@ -519,20 +520,28 @@ class CustomAttributes(BaseAction): self.show_message(event, msg) def process_attribute(self, data): - existing_attrs = self.session.query( - "CustomAttributeConfiguration" - ).all() + existing_attrs = self.session.query(( + "select is_hierarchical, key, type, entity_type, object_type_id" + " from CustomAttributeConfiguration" + )).all() matching = [] + is_hierarchical = data.get("is_hierarchical", False) for attr in existing_attrs: if ( - attr["key"] != data["key"] or - attr["type"]["name"] != data["type"]["name"] + is_hierarchical != attr["is_hierarchical"] + or attr["key"] != data["key"] ): continue - if data.get("is_hierarchical") is True: - if attr["is_hierarchical"] is True: - matching.append(attr) + if attr["type"]["name"] != data["type"]["name"]: + if data["key"] in FPS_KEYS and attr["type"]["name"] == "text": + self.log.info("Kept 'fps' as text custom attribute.") + return + continue + + if is_hierarchical: + matching.append(attr) + elif "object_type_id" in data: if ( attr["entity_type"] == data["entity_type"] and diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index 80b4db9dd6..7fc2bc99eb 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -4,7 +4,8 @@ from .constants import ( CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, - CUST_ATTR_INTENT + CUST_ATTR_INTENT, + FPS_KEYS ) from .settings import ( get_ftrack_event_mongo_info @@ -30,6 +31,8 @@ __all__ = ( "CUST_ATTR_GROUP", "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", + "CUST_ATTR_INTENT", + "FPS_KEYS", "get_ftrack_event_mongo_info", diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index db7c592c9b..5301ec568e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -2,6 +2,9 @@ import re import json import collections import copy +import numbers + +import six from avalon.api import AvalonMongoDB @@ -14,7 +17,7 @@ from openpype.api import ( ) from openpype.lib import ApplicationManager -from .constants import CUST_ATTR_ID_KEY +from .constants import CUST_ATTR_ID_KEY, FPS_KEYS from .custom_attributes import get_openpype_attr, query_custom_attributes from bson.objectid import ObjectId @@ -33,6 +36,106 @@ CURRENT_DOC_SCHEMAS = { } +class InvalidFpsValue(Exception): + pass + + +def is_string_number(value): + """Can string value be converted to number (float).""" + if not isinstance(value, six.string_types): + raise TypeError("Expected {} got {}".format( + ", ".join(str(t) for t in six.string_types), str(type(value)) + )) + if value == ".": + return False + + if value.startswith("."): + value = "0" + value + elif value.endswith("."): + value = value + "0" + + if re.match(r"^\d+(\.\d+)?$", value) is None: + return False + return True + + +def convert_to_fps(source_value): + """Convert value into fps value. + + Non string values are kept untouched. String is tried to convert. + Valid values: + "1000" + "1000.05" + "1000,05" + ",05" + ".05" + "1000," + "1000." + "1000/1000" + "1000.05/1000" + "1000/1000.05" + "1000.05/1000.05" + "1000,05/1000" + "1000/1000,05" + "1000,05/1000,05" + + Invalid values: + "/" + "/1000" + "1000/" + "," + "." + ...any other string + + Returns: + float: Converted value. + + Raises: + InvalidFpsValue: When value can't be converted to float. + """ + if not isinstance(source_value, six.string_types): + if isinstance(source_value, numbers.Number): + return float(source_value) + return source_value + + value = source_value.strip().replace(",", ".") + if not value: + raise InvalidFpsValue("Got empty value") + + subs = value.split("/") + if len(subs) == 1: + str_value = subs[0] + if not is_string_number(str_value): + raise InvalidFpsValue( + "Value \"{}\" can't be converted to number.".format(value) + ) + return float(str_value) + + elif len(subs) == 2: + divident, divisor = subs + if not divident or not is_string_number(divident): + raise InvalidFpsValue( + "Divident value \"{}\" can't be converted to number".format( + divident + ) + ) + + if not divisor or not is_string_number(divisor): + raise InvalidFpsValue( + "Divisor value \"{}\" can't be converted to number".format( + divident + ) + ) + divisor_float = float(divisor) + if divisor_float == 0.0: + raise InvalidFpsValue("Can't divide by zero") + return float(divident) / divisor_float + + raise InvalidFpsValue( + "Value can't be converted to number \"{}\"".format(source_value) + ) + + def create_chunks(iterable, chunk_size=None): """Separate iterable into multiple chunks by size. @@ -980,6 +1083,7 @@ class SyncEntitiesFactory: sync_ids ) + invalid_fps_items = [] for item in items: entity_id = item["entity_id"] attr_id = item["configuration_id"] @@ -992,8 +1096,24 @@ class SyncEntitiesFactory: value = item["value"] if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # process hierarchical attributes self.set_hierarchical_attribute( hier_attrs, sync_ids, cust_attr_type_name_by_id @@ -1026,8 +1146,15 @@ class SyncEntitiesFactory: if key.startswith("avalon_"): store_key = "avalon_attrs" + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + self.entities_dict[self.ft_project_id][store_key][key] = ( - attr["default"] + default_value ) # Add attribute ids to entities dictionary @@ -1069,6 +1196,7 @@ class SyncEntitiesFactory: True ) + invalid_fps_items = [] avalon_hier = [] for item in items: value = item["value"] @@ -1088,6 +1216,13 @@ class SyncEntitiesFactory: entity_id = item["entity_id"] key = attribute_key_by_id[attr_id] + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue + if key.startswith("avalon_"): store_key = "avalon_attrs" avalon_hier.append(key) @@ -1095,6 +1230,16 @@ class SyncEntitiesFactory: store_key = "hier_attrs" self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs top_id = self.ft_project_id project_values = {} diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py index e6e2013d2b..636dcfbc3d 100644 --- a/openpype/modules/ftrack/lib/constants.py +++ b/openpype/modules/ftrack/lib/constants.py @@ -12,3 +12,9 @@ CUST_ATTR_APPLICATIONS = "applications" CUST_ATTR_TOOLS = "tools_env" # Intent custom attribute name CUST_ATTR_INTENT = "intent" + +FPS_KEYS = { + "fps", + # For development purposes + "fps_string" +} diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index e11d32091f..706279fd72 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -399,15 +399,6 @@ class CreatedInstance: self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator.identifier - # QUESTION handle version of instance here or in creator? - version = None - if not new: - version = data.get("version") - - if version is None: - version = 1 - self._data["version"] = version - # Pop from source data all keys that are defined in `_data` before # this moment and through their values away # - they should be the same and if are not then should not change diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index d80b7bb9c3..468ed96199 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -34,7 +34,12 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.info("subset {}".format(instance.data['subset'])) # skip crypto passes. - if 'crypto' in instance.data['subset']: + # TODO: This is just a quick fix and has its own side-effects - it is + # affecting every subset name with `crypto` in its name. + # This must be solved properly, maybe using tags on + # representation that can be determined much earlier and + # with better precision. + if 'crypto' in instance.data['subset'].lower(): self.log.info("Skipping crypto passes.") return diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 5f286a53e6..b4a5117959 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1171,6 +1171,9 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("input_width: `{}`".format(input_width)) self.log.debug("input_height: `{}`".format(input_height)) + reformat_in_baking = bool("reformated" in new_repre["tags"]) + self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) + # Use instance resolution if output definition has not set it. if output_width is None or output_height is None: output_width = temp_data["resolution_width"] @@ -1182,6 +1185,17 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = input_width output_height = input_height + if reformat_in_baking: + self.log.debug(( + "Using resolution from input. It is already " + "reformated from baking process" + )) + output_width = input_width + output_height = input_height + pixel_aspect = 1 + new_repre["resolutionWidth"] = input_width + new_repre["resolutionHeight"] = input_height + output_width = int(output_width) output_height = int(output_height) diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py index 26f5356336..6db8ff36a8 100644 --- a/openpype/scripts/fusion_switch_shot.py +++ b/openpype/scripts/fusion_switch_shot.py @@ -4,13 +4,15 @@ import sys import logging # Pipeline imports -from avalon import api, io, pipeline +from avalon import api, io import avalon.fusion # Config imports import openpype.lib as pype import openpype.hosts.fusion.lib as fusion_lib +from openpype.lib.avalon_context import get_workdir_from_session + log = logging.getLogger("Update Slap Comp") self = sys.modules[__name__] @@ -44,16 +46,6 @@ def _format_version_folder(folder): return version_folder -def _get_work_folder(session): - """Convenience function to get the work folder path of the current asset""" - - # Get new filename, create path based on asset and work template - template_work = self._project["config"]["template"]["work"] - work_path = pipeline._format_work_template(template_work, session) - - return os.path.normpath(work_path) - - def _get_fusion_instance(): fusion = getattr(sys.modules["__main__"], "fusion", None) if fusion is None: @@ -72,7 +64,7 @@ def _format_filepath(session): asset = session["AVALON_ASSET"] # Save updated slap comp - work_path = _get_work_folder(session) + work_path = get_workdir_from_session(session) walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") slapcomp_dir = os.path.abspath(walk_to_dir) @@ -103,7 +95,7 @@ def _update_savers(comp, session): None """ - new_work = _get_work_folder(session) + new_work = get_workdir_from_session(session) renders = os.path.join(new_work, "renders") version_folder = _format_version_folder(renders) renders_version = os.path.join(renders, version_folder) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 24e8e4a29b..c25f416562 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -589,6 +589,12 @@ 12, 255 ], + "vrayscene_layer": [ + 255, + 150, + 12, + 255 + ], "yeticache": [ 99, 206, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 5a819e6904..6992fb6e3e 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -116,13 +116,42 @@ "baking": { "filter": { "task_types": [], - "families": [] + "families": [], + "sebsets": [] }, "extension": "mov", "viewer_process_override": "", "bake_viewer_process": true, "bake_viewer_input_process": true, - "add_tags": [] + "add_tags": [], + "reformat_node_add": false, + "reformat_node_config": [ + { + "type": "string", + "name": "type", + "value": "to format" + }, + { + "type": "string", + "name": "format", + "value": "HD_1080" + }, + { + "type": "string", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] } } }, diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 963fd406ed..19f326aea7 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -584,8 +584,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_default_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_default_value(child_value) def update_studio_value(self, value): @@ -620,8 +621,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_studio_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_studio_value(child_value) def update_project_value(self, value): @@ -656,8 +658,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_project_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_project_value(child_value) def _discard_changes(self, on_change_trigger): diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 7c87644817..6b2315abc0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -75,6 +75,11 @@ "label": "Vray Proxy:", "key": "vrayproxy" }, + { + "type": "color", + "label": "Vray Scene:", + "key": "vrayscene_layer" + }, { "type": "color", "label": "Yeti Cache:", 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 39390f355a..1636a8d700 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 @@ -195,6 +195,12 @@ "label": "Families", "type": "list", "object_type": "text" + }, + { + "key": "sebsets", + "label": "Subsets", + "type": "list", + "object_type": "text" } ] }, @@ -226,6 +232,121 @@ "label": "Add additional tags to representations", "type": "list", "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "reformat_node_add", + "label": "Add Reformat Node", + "default": false + }, + { + "type": "collapsible-wrap", + "label": "Reformat Node Knobs", + "collapsible": true, + "collapsed": false, + "children": [ + { + "type": "list", + "key": "reformat_node_config", + "object_type": { + "type": "dict-conditional", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ + { + "key": "string", + "label": "String", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "bool", + "label": "Boolean", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "boolean", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "number", + "label": "Number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "number", + "default": 1, + "decimal": 4 + } + ] + } + + ] + }, + { + "key": "list_numbers", + "label": "2 Numbers", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "default": 1, + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "default": 1, + "decimal": 4 + } + ] + } + ] + } + ] + } + } + ] } ] } diff --git a/openpype/tools/assetcreator/app.py b/openpype/tools/assetcreator/app.py index 1d332d647e..60ef31e859 100644 --- a/openpype/tools/assetcreator/app.py +++ b/openpype/tools/assetcreator/app.py @@ -4,9 +4,11 @@ from subprocess import Popen import ftrack_api from Qt import QtWidgets, QtCore +from openpype import style from openpype.api import get_current_project_settings +from openpype.lib.avalon_context import update_current_task from openpype.tools.utils.lib import qt_app_context -from avalon import io, api, style, schema +from avalon import io, api, schema from . import widget, model module = sys.modules[__name__] @@ -463,12 +465,12 @@ class Window(QtWidgets.QDialog): return task_name = task_model.itemData(index)[0] try: - api.update_current_task(task=task_name, asset=asset_name) + update_current_task(task=task_name, asset=asset_name) self.open_app() finally: if origin_task is not None and origin_asset is not None: - api.update_current_task( + update_current_task( task=origin_task, asset=origin_asset ) diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index d575e647ce..e546ee705d 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -3,9 +3,11 @@ from collections import defaultdict from Qt import QtWidgets, QtCore -# TODO: expose this better in avalon core -from avalon.tools import lib -from avalon.tools.models import TreeModel +from openpype.tools.utils.models import TreeModel +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) from .models import ( AssetModel, @@ -88,8 +90,8 @@ class AssetOutliner(QtWidgets.QWidget): """Add all items from the current scene""" items = [] - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) @@ -100,8 +102,8 @@ class AssetOutliner(QtWidgets.QWidget): def get_selected_assets(self): """Add all selected items from the current scene""" - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_selected_nodes() items = commands.create_items_from_nodes(nodes) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index d2b7f8b70f..6435e5c488 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -8,7 +8,7 @@ from avalon import api, io, style, schema from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item from .lib import ( get_site_icons, diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 80f26a881d..ec48b10e47 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -7,9 +7,13 @@ from Qt import QtWidgets, QtCore from avalon import io, api, style from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools import lib as tools_lib from openpype.modules import ModulesManager +from openpype.tools.utils.lib import ( + get_progress_for_repre, + iter_model_rows, + format_version +) from .switch_dialog import SwitchAssetDialog from .model import InventoryModel @@ -20,12 +24,12 @@ DEFAULT_COLOR = "#fb9c15" log = logging.getLogger("SceneInventory") -class SceneInvetoryView(QtWidgets.QTreeView): +class SceneInventoryView(QtWidgets.QTreeView): data_changed = QtCore.Signal() hierarchy_view_changed = QtCore.Signal(bool) def __init__(self, parent=None): - super(SceneInvetoryView, self).__init__(parent=parent) + super(SceneInventoryView, self).__init__(parent=parent) # view settings self.setIndentation(12) @@ -373,7 +377,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): if not repre_doc: continue - progress = tools_lib.get_progress_for_repre( + progress = get_progress_for_repre( repre_doc, active_site, remote_site @@ -544,7 +548,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): "toggle": selection_model.Toggle, }[options.get("mode", "select")] - for item in tools_lib.iter_model_rows(model, 0): + for item in iter_model_rows(model, 0): item = item.data(InventoryModel.ItemRole) if item.get("isGroupNode"): continue @@ -704,7 +708,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): labels = [] for version in all_versions: is_hero = version["type"] == "hero_version" - label = tools_lib.format_version(version["name"], is_hero) + label = format_version(version["name"], is_hero) labels.append(label) versions_by_label[label] = version["name"] @@ -792,3 +796,40 @@ class SceneInvetoryView(QtWidgets.QTreeView): ).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 + for item in outdated_items: + try: + api.update(item, -1) + except AssertionError: + self._show_version_error_dialog(None, [item]) + log.warning("Update failed", exc_info=True) + self.data_changed.emit() diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index e363a99d07..095d30cac0 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -18,7 +18,7 @@ from .model import ( InventoryModel, FilterProxyModel ) -from .view import SceneInvetoryView +from .view import SceneInventoryView module = sys.modules[__name__] @@ -54,14 +54,21 @@ class SceneInventoryWindow(QtWidgets.QDialog): 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) + update_all_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) # endregion control @@ -73,7 +80,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): proxy.setDynamicSortFilter(True) proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - view = SceneInvetoryView(self) + view = SceneInventoryView(self) view.setModel(proxy) # set some nice default widths for the view @@ -98,11 +105,13 @@ class SceneInventoryWindow(QtWidgets.QDialog): self._on_outdated_state_change ) view.hierarchy_view_changed.connect( - self._on_hiearchy_view_change + self._on_hierarchy_view_change ) view.data_changed.connect(self.refresh) refresh_button.clicked.connect(self.refresh) + update_all_button.clicked.connect(self._on_update_all) + self._update_all_button = update_all_button self._outdated_only_checkbox = outdated_only_checkbox self._view = view self._model = model @@ -146,7 +155,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): kwargs["selected"] = self._view._selected self._model.refresh(**kwargs) - def _on_hiearchy_view_change(self, enabled): + def _on_hierarchy_view_change(self, enabled): self._proxy.set_hierarchy_view(enabled) self._model.set_hierarchy_view(enabled) @@ -158,6 +167,9 @@ class SceneInventoryWindow(QtWidgets.QDialog): self._outdated_only_checkbox.isChecked() ) + def _on_update_all(self): + self._view.update_all() + def show(root=None, debug=False, parent=None, items=None): """Display Scene Inventory GUI diff --git a/openpype/tools/standalonepublish/publish.py b/openpype/tools/standalonepublish/publish.py index af269c4381..582e7eccf8 100644 --- a/openpype/tools/standalonepublish/publish.py +++ b/openpype/tools/standalonepublish/publish.py @@ -3,10 +3,10 @@ import sys import openpype import pyblish.api +from openpype.tools.utils.host_tools import show_publish def main(env): - from avalon.tools import publish # Registers pype's Global pyblish plugins openpype.install() @@ -19,7 +19,7 @@ def main(env): continue pyblish.api.register_plugin_path(path) - return publish.show() + return show_publish() if __name__ == "__main__": diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index b4b0af106e..c15e9f8139 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,7 @@ from .lib import ( get_warning_pixmap, set_style_property, DynamicQThread, + qt_app_context, ) from .models import ( @@ -39,6 +40,7 @@ __all__ = ( "get_warning_pixmap", "set_style_property", "DynamicQThread", + "qt_app_context", "RecursiveSortFilterProxyModel", ) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index a7ad8fef3b..6ce9e818d9 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -3,8 +3,9 @@ It is possible to create `HostToolsHelper` in host implementation or use singleton approach with global functions (using helper anyway). """ - +import os import avalon.api +import pyblish.api from .lib import qt_app_context @@ -196,10 +197,29 @@ class HostToolsHelper: library_loader_tool.refresh() def show_publish(self, parent=None): - """Publish UI.""" - from avalon.tools import publish + """Try showing the most desirable publish GUI - publish.show(parent) + This function cycles through the currently registered + graphical user interfaces, if any, and presents it to + the user. + """ + + pyblish_show = self._discover_pyblish_gui() + return pyblish_show(parent) + + def _discover_pyblish_gui(self): + """Return the most desirable of the currently registered GUIs""" + # Prefer last registered + guis = list(reversed(pyblish.api.registered_guis())) + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + raise ImportError("No Pyblish GUI found") def get_look_assigner_tool(self, parent): """Create, cache and return look assigner tool window.""" @@ -394,3 +414,11 @@ def show_publish(parent=None): def show_experimental_tools_dialog(parent=None): _SingletonPoint.show_tool_by_name("experimental_tools", parent) + + +def get_pyblish_icon(): + pyblish_dir = os.path.abspath(os.path.dirname(pyblish.api.__file__)) + icon_path = os.path.join(pyblish_dir, "icons", "logo-32x32.svg") + if os.path.exists(icon_path): + return icon_path + return None diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 3a772a038c..aece7bfb4f 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -29,6 +29,10 @@ from openpype.lib import ( create_workdir_extra_folders, get_system_general_anatomy_data ) +from openpype.lib.avalon_context import ( + update_current_task, + compute_session_changes +) from .model import FilesModel from .view import FilesView @@ -667,7 +671,7 @@ class FilesWidget(QtWidgets.QWidget): session["AVALON_APP"], project_name=session["AVALON_PROJECT"] ) - changes = pipeline.compute_session_changes( + changes = compute_session_changes( session, asset=self._get_asset_doc(), task=self._task_name, @@ -681,7 +685,7 @@ class FilesWidget(QtWidgets.QWidget): """Enter the asset and task session currently selected""" session = api.Session.copy() - changes = pipeline.compute_session_changes( + changes = compute_session_changes( session, asset=self._get_asset_doc(), task=self._task_name, @@ -692,7 +696,7 @@ class FilesWidget(QtWidgets.QWidget): # to avoid any unwanted Task Changed callbacks to be triggered. return - api.update_current_task( + update_current_task( asset=self._get_asset_doc(), task=self._task_name, template_key=self.template_key diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 583f495606..3425cc3df0 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,11 +1,11 @@ import os import logging -from Qt import QtCore, QtGui +from Qt import QtCore from avalon import style from avalon.vendor import qtawesome -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__)