diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2e854061d5..7d6c5650d1 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.18.3-nightly.2 - 3.18.3-nightly.1 - 3.18.2 - 3.18.2-nightly.6 @@ -134,7 +135,6 @@ body: - 3.15.6 - 3.15.6-nightly.3 - 3.15.6-nightly.2 - - 3.15.6-nightly.1 validations: required: true - type: dropdown diff --git a/openpype/client/server/entity_links.py b/openpype/client/server/entity_links.py index 368dcdcb9d..7fb9fbde6f 100644 --- a/openpype/client/server/entity_links.py +++ b/openpype/client/server/entity_links.py @@ -124,23 +124,24 @@ def get_linked_representation_id( if not versions_to_check: break - links = con.get_versions_links( + versions_links = con.get_versions_links( project_name, versions_to_check, link_types=link_types, link_direction="out") versions_to_check = set() - for link in links: - # Care only about version links - if link["entityType"] != "version": - continue - entity_id = link["entityId"] - # Skip already found linked version ids - if entity_id in linked_version_ids: - continue - linked_version_ids.add(entity_id) - versions_to_check.add(entity_id) + for links in versions_links.values(): + for link in links: + # Care only about version links + if link["entityType"] != "version": + continue + entity_id = link["entityId"] + # Skip already found linked version ids + if entity_id in linked_version_ids: + continue + linked_version_ids.add(entity_id) + versions_to_check.add(entity_id) linked_version_ids.remove(version_id) if not linked_version_ids: diff --git a/openpype/hosts/blender/plugins/create/create_workfile.py b/openpype/hosts/blender/plugins/create/create_workfile.py index ceec3e0552..6b168f4c84 100644 --- a/openpype/hosts/blender/plugins/create/create_workfile.py +++ b/openpype/hosts/blender/plugins/create/create_workfile.py @@ -25,7 +25,7 @@ class CreateWorkfile(BaseCreator, AutoCreator): def create(self): """Create workfile instances.""" - existing_instance = next( + workfile_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier @@ -39,14 +39,14 @@ class CreateWorkfile(BaseCreator, AutoCreator): host_name = self.create_context.host_name existing_asset_name = None - if existing_instance is not None: + if workfile_instance is not None: if AYON_SERVER_ENABLED: - existing_asset_name = existing_instance.get("folderPath") + existing_asset_name = workfile_instance.get("folderPath") if existing_asset_name is None: - existing_asset_name = existing_instance["asset"] + existing_asset_name = workfile_instance["asset"] - if not existing_instance: + if not workfile_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 @@ -66,19 +66,18 @@ class CreateWorkfile(BaseCreator, AutoCreator): asset_doc, project_name, host_name, - existing_instance, + workfile_instance, ) ) self.log.info("Auto-creating workfile instance...") - current_instance = CreatedInstance( + workfile_instance = CreatedInstance( self.family, subset_name, data, self ) - instance_node = bpy.data.collections.get(AVALON_CONTAINERS, {}) - current_instance.transient_data["instance_node"] = instance_node - self._add_instance_to_context(current_instance) + self._add_instance_to_context(workfile_instance) + elif ( existing_asset_name != asset_name - or existing_instance["task"] != task_name + or workfile_instance["task"] != task_name ): # Update instance context if it's different asset_doc = get_asset_by_name(project_name, asset_name) @@ -86,12 +85,17 @@ class CreateWorkfile(BaseCreator, AutoCreator): task_name, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: - existing_instance["folderPath"] = asset_name + workfile_instance["folderPath"] = asset_name else: - existing_instance["asset"] = asset_name + workfile_instance["asset"] = asset_name - existing_instance["task"] = task_name - existing_instance["subset"] = subset_name + workfile_instance["task"] = task_name + workfile_instance["subset"] = subset_name + + instance_node = bpy.data.collections.get(AVALON_CONTAINERS) + if not instance_node: + instance_node = bpy.data.collections.new(name=AVALON_CONTAINERS) + workfile_instance.transient_data["instance_node"] = instance_node def collect_instances(self): diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 576628e876..3da8968727 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -64,5 +64,8 @@ class FusionPrelaunch(PreLaunchHook): self.launch_context.env[py3_var] = py3_dir + # for hook installing PySide2 + self.data["fusion_python3_home"] = py3_dir + self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}") self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR diff --git a/openpype/hosts/fusion/hooks/pre_pyside_install.py b/openpype/hosts/fusion/hooks/pre_pyside_install.py new file mode 100644 index 0000000000..f98aeda233 --- /dev/null +++ b/openpype/hosts/fusion/hooks/pre_pyside_install.py @@ -0,0 +1,186 @@ +import os +import subprocess +import platform +import uuid + +from openpype.lib.applications import PreLaunchHook, LaunchTypes + + +class InstallPySideToFusion(PreLaunchHook): + """Automatically installs Qt binding to fusion's python packages. + + Check if fusion has installed PySide2 and will try to install if not. + + For pipeline implementation is required to have Qt binding installed in + fusion's python packages. + """ + + app_groups = {"fusion"} + order = 2 + launch_types = {LaunchTypes.local} + + def execute(self): + # Prelaunch hook is not crucial + try: + settings = self.data["project_settings"][self.host_name] + if not settings["hooks"]["InstallPySideToFusion"]["enabled"]: + return + self.inner_execute() + except Exception: + self.log.warning( + "Processing of {} crashed.".format(self.__class__.__name__), + exc_info=True + ) + + def inner_execute(self): + self.log.debug("Check for PySide2 installation.") + + fusion_python3_home = self.data.get("fusion_python3_home") + if not fusion_python3_home: + self.log.warning("'fusion_python3_home' was not provided. " + "Installation of PySide2 not possible") + return + + if platform.system().lower() == "windows": + exe_filenames = ["python.exe"] + else: + exe_filenames = ["python3", "python"] + + for exe_filename in exe_filenames: + python_executable = os.path.join(fusion_python3_home, exe_filename) + if os.path.exists(python_executable): + break + + if not os.path.exists(python_executable): + self.log.warning( + "Couldn't find python executable for fusion. {}".format( + python_executable + ) + ) + return + + # Check if PySide2 is installed and skip if yes + if self._is_pyside_installed(python_executable): + self.log.debug("Fusion has already installed PySide2.") + return + + self.log.debug("Installing PySide2.") + # Install PySide2 in fusion's python + if self._windows_require_permissions( + os.path.dirname(python_executable)): + result = self._install_pyside_windows(python_executable) + else: + result = self._install_pyside(python_executable) + + if result: + self.log.info("Successfully installed PySide2 module to fusion.") + else: + self.log.warning("Failed to install PySide2 module to fusion.") + + def _install_pyside_windows(self, python_executable): + """Install PySide2 python module to fusion's python. + + Installation requires administration rights that's why it is required + to use "pywin32" module which can execute command's and ask for + administration rights. + """ + try: + import win32api + import win32con + import win32process + import win32event + import pywintypes + from win32comext.shell.shell import ShellExecuteEx + from win32comext.shell import shellcon + except Exception: + self.log.warning("Couldn't import \"pywin32\" modules") + return False + + try: + # Parameters + # - use "-m pip" as module pip to install PySide2 and argument + # "--ignore-installed" is to force install module to fusion's + # site-packages and make sure it is binary compatible + parameters = "-m pip install --ignore-installed PySide2" + + # Execute command and ask for administrator's rights + process_info = ShellExecuteEx( + nShow=win32con.SW_SHOWNORMAL, + fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, + lpVerb="runas", + lpFile=python_executable, + lpParameters=parameters, + lpDirectory=os.path.dirname(python_executable) + ) + process_handle = process_info["hProcess"] + win32event.WaitForSingleObject(process_handle, + win32event.INFINITE) + returncode = win32process.GetExitCodeProcess(process_handle) + return returncode == 0 + except pywintypes.error: + return False + + def _install_pyside(self, python_executable): + """Install PySide2 python module to fusion's python.""" + try: + # Parameters + # - use "-m pip" as module pip to install PySide2 and argument + # "--ignore-installed" is to force install module to fusion's + # site-packages and make sure it is binary compatible + env = dict(os.environ) + del env['PYTHONPATH'] + args = [ + python_executable, + "-m", + "pip", + "install", + "--ignore-installed", + "PySide2", + ] + process = subprocess.Popen( + args, stdout=subprocess.PIPE, universal_newlines=True, + env=env + ) + process.communicate() + return process.returncode == 0 + except PermissionError: + self.log.warning( + "Permission denied with command:" + "\"{}\".".format(" ".join(args)) + ) + except OSError as error: + self.log.warning(f"OS error has occurred: \"{error}\".") + except subprocess.SubprocessError: + pass + + def _is_pyside_installed(self, python_executable): + """Check if PySide2 module is in fusion's pip list.""" + args = [python_executable, "-c", "from qtpy import QtWidgets"] + process = subprocess.Popen(args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + _, stderr = process.communicate() + stderr = stderr.decode() + if stderr: + return False + return True + + def _windows_require_permissions(self, dirpath): + if platform.system().lower() != "windows": + return False + + try: + # Attempt to create a temporary file in the folder + temp_file_path = os.path.join(dirpath, uuid.uuid4().hex) + with open(temp_file_path, "w"): + pass + os.remove(temp_file_path) # Clean up temporary file + return False + + except PermissionError: + return True + + except BaseException as exc: + print(("Failed to determine if root requires permissions." + "Unexpected error: {}").format(exc)) + return False diff --git a/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py b/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py new file mode 100644 index 0000000000..efd7c6d0ca --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py @@ -0,0 +1,112 @@ +import os +import re +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline +from openpype.pipeline.load import LoadError + +import hou + + +class RedshiftProxyLoader(load.LoaderPlugin): + """Load Redshift Proxy""" + + families = ["redshiftproxy"] + label = "Load Redshift Proxy" + representations = ["rs"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create a new geo node + container = obj.createNode("geo", node_name=node_name) + + # Check whether the Redshift parameters exist - if not, then likely + # redshift is not set up or initialized correctly + if not container.parm("RS_objprop_proxy_enable"): + container.destroy() + raise LoadError("Unable to initialize geo node with Redshift " + "attributes. Make sure you have the Redshift " + "plug-in set up correctly for Houdini.") + + # Enable by default + container.setParms({ + "RS_objprop_proxy_enable": True, + "RS_objprop_proxy_file": self.format_path( + self.filepath_from_context(context), + context["representation"]) + }) + + # Remove the file node, it only loads static meshes + # Houdini 17 has removed the file node from the geo node + file_node = container.node("file1") + if file_node: + file_node.destroy() + + # Add this stub node inside so it previews ok + proxy_sop = container.createNode("redshift_proxySOP", + node_name=node_name) + proxy_sop.setDisplayFlag(True) + + nodes = [container, proxy_sop] + + self[:] = nodes + + return pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + def update(self, container, representation): + + # Update the file path + file_path = get_representation_path(representation) + + node = container["node"] + node.setParms({ + "RS_objprop_proxy_file": self.format_path( + file_path, representation) + }) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() + + @staticmethod + def format_path(path, representation): + """Format file path correctly for single redshift proxy + or redshift proxy sequence.""" + if not os.path.exists(path): + raise RuntimeError("Path does not exist: %s" % path) + + is_sequence = bool(representation["context"].get("frame")) + # The path is either a single file or sequence in a folder. + if is_sequence: + filename = re.sub(r"(.*)\.(\d+)\.(rs.*)", "\\1.$F4.\\3", path) + filename = os.path.join(path, filename) + else: + filename = path + + filename = os.path.normpath(filename) + filename = filename.replace("\\", "/") + + return filename diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 88c587faf6..7ba53caead 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3483,3 +3483,19 @@ def get_filenames_without_hash(filename, frame_start, frame_end): new_filename = filename_without_hashes.format(frame) filenames.append(new_filename) return filenames + + +def create_camera_node_by_version(): + """Function to create the camera with the latest node class + For Nuke version 14.0 or later, the Camera4 camera node class + would be used + For the version before, the Camera2 camera node class + would be used + Returns: + Node: camera node + """ + nuke_number_version = nuke.NUKE_VERSION_MAJOR + if nuke_number_version >= 14: + return nuke.createNode("Camera4") + else: + return nuke.createNode("Camera2") diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 12562a6b6f..c2fc684c21 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -259,9 +259,7 @@ def _install_menu(): menu.addCommand( "Create...", lambda: host_tools.show_publisher( - parent=( - main_window if nuke.NUKE_VERSION_MAJOR >= 14 else None - ), + parent=main_window, tab="create" ) ) @@ -270,9 +268,7 @@ def _install_menu(): menu.addCommand( "Publish...", lambda: host_tools.show_publisher( - parent=( - main_window if nuke.NUKE_VERSION_MAJOR >= 14 else None - ), + parent=main_window, tab="publish" ) ) diff --git a/openpype/hosts/nuke/plugins/create/create_camera.py b/openpype/hosts/nuke/plugins/create/create_camera.py index b84280b11b..be9c69213e 100644 --- a/openpype/hosts/nuke/plugins/create/create_camera.py +++ b/openpype/hosts/nuke/plugins/create/create_camera.py @@ -4,6 +4,9 @@ from openpype.hosts.nuke.api import ( NukeCreatorError, maintained_selection ) +from openpype.hosts.nuke.api.lib import ( + create_camera_node_by_version +) class CreateCamera(NukeCreator): @@ -32,7 +35,7 @@ class CreateCamera(NukeCreator): "Creator error: Select only camera node type") created_node = self.selected_nodes[0] else: - created_node = nuke.createNode("Camera2") + created_node = create_camera_node_by_version() created_node["tile_color"].setValue( int(self.node_color, 16)) diff --git a/openpype/modules/kitsu/utils/credentials.py b/openpype/modules/kitsu/utils/credentials.py index 941343cc8d..c471b56907 100644 --- a/openpype/modules/kitsu/utils/credentials.py +++ b/openpype/modules/kitsu/utils/credentials.py @@ -64,8 +64,10 @@ def clear_credentials(): user_registry = OpenPypeSecureRegistry("kitsu_user") # Set local settings - user_registry.delete_item("login") - user_registry.delete_item("password") + if user_registry.get_item("login", None) is not None: + user_registry.delete_item("login") + if user_registry.get_item("password", None) is not None: + user_registry.delete_item("password") def save_credentials(login: str, password: str): @@ -92,8 +94,9 @@ def load_credentials() -> Tuple[str, str]: # Get user registry user_registry = OpenPypeSecureRegistry("kitsu_user") - return user_registry.get_item("login", None), user_registry.get_item( - "password", None + return ( + user_registry.get_item("login", None), + user_registry.get_item("password", None) ) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 1b4b44e40e..0a34848166 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -190,47 +190,18 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): project_task_types = project_doc["config"]["tasks"] for instance in context: - asset_doc = instance.data.get("assetEntity") - anatomy_updates = { + anatomy_data = copy.deepcopy(context.data["anatomyData"]) + anatomy_data.update({ "family": instance.data["family"], "subset": instance.data["subset"], - } - if asset_doc: - parents = asset_doc["data"].get("parents") or list() - parent_name = project_doc["name"] - if parents: - parent_name = parents[-1] + }) - hierarchy = "/".join(parents) - anatomy_updates.update({ - "asset": asset_doc["name"], - "hierarchy": hierarchy, - "parent": parent_name, - "folder": { - "name": asset_doc["name"], - }, - }) - - # Task - task_type = None - task_name = instance.data.get("task") - if task_name: - asset_tasks = asset_doc["data"]["tasks"] - task_type = asset_tasks.get(task_name, {}).get("type") - task_code = ( - project_task_types - .get(task_type, {}) - .get("short_name") - ) - anatomy_updates["task"] = { - "name": task_name, - "type": task_type, - "short": task_code - } + self._fill_asset_data(instance, project_doc, anatomy_data) + self._fill_task_data(instance, project_task_types, anatomy_data) # Define version if self.follow_workfile_version: - version_number = context.data('version') + version_number = context.data("version") else: version_number = instance.data.get("version") @@ -242,6 +213,9 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # If version is not specified for instance or context if version_number is None: + task_data = anatomy_data.get("task") or {} + task_name = task_data.get("name") + task_type = task_data.get("type") version_number = get_versioning_start( context.data["projectName"], instance.context.data["hostName"], @@ -250,29 +224,26 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): family=instance.data["family"], subset=instance.data["subset"] ) - anatomy_updates["version"] = version_number + anatomy_data["version"] = version_number # Additional data resolution_width = instance.data.get("resolutionWidth") if resolution_width: - anatomy_updates["resolution_width"] = resolution_width + anatomy_data["resolution_width"] = resolution_width resolution_height = instance.data.get("resolutionHeight") if resolution_height: - anatomy_updates["resolution_height"] = resolution_height + anatomy_data["resolution_height"] = resolution_height pixel_aspect = instance.data.get("pixelAspect") if pixel_aspect: - anatomy_updates["pixel_aspect"] = float( + anatomy_data["pixel_aspect"] = float( "{:0.2f}".format(float(pixel_aspect)) ) fps = instance.data.get("fps") if fps: - anatomy_updates["fps"] = float("{:0.2f}".format(float(fps))) - - anatomy_data = copy.deepcopy(context.data["anatomyData"]) - anatomy_data.update(anatomy_updates) + anatomy_data["fps"] = float("{:0.2f}".format(float(fps))) # Store anatomy data instance.data["projectEntity"] = project_doc @@ -288,3 +259,157 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): instance_name, json.dumps(anatomy_data, indent=4) )) + + def _fill_asset_data(self, instance, project_doc, anatomy_data): + # QUESTION should we make sure that all asset data are poped if asset + # data cannot be found? + # - 'asset', 'hierarchy', 'parent', 'folder' + asset_doc = instance.data.get("assetEntity") + if asset_doc: + parents = asset_doc["data"].get("parents") or list() + parent_name = project_doc["name"] + if parents: + parent_name = parents[-1] + + hierarchy = "/".join(parents) + anatomy_data.update({ + "asset": asset_doc["name"], + "hierarchy": hierarchy, + "parent": parent_name, + "folder": { + "name": asset_doc["name"], + }, + }) + return + + if instance.data.get("newAssetPublishing"): + hierarchy = instance.data["hierarchy"] + anatomy_data["hierarchy"] = hierarchy + + parent_name = project_doc["name"] + if hierarchy: + parent_name = hierarchy.split("/")[-1] + + asset_name = instance.data["asset"].split("/")[-1] + anatomy_data.update({ + "asset": asset_name, + "hierarchy": hierarchy, + "parent": parent_name, + "folder": { + "name": asset_name, + }, + }) + + def _fill_task_data(self, instance, project_task_types, anatomy_data): + # QUESTION should we make sure that all task data are poped if task + # data cannot be resolved? + # - 'task' + + # Skip if there is no task + task_name = instance.data.get("task") + if not task_name: + return + + # Find task data based on asset entity + asset_doc = instance.data.get("assetEntity") + task_data = self._get_task_data_from_asset( + asset_doc, task_name, project_task_types + ) + if task_data: + # Fill task data + # - if we're in editorial, make sure the task type is filled + if ( + not instance.data.get("newAssetPublishing") + or task_data["type"] + ): + anatomy_data["task"] = task_data + return + + # New hierarchy is not created, so we can only skip rest of the logic + if not instance.data.get("newAssetPublishing"): + return + + # Try to find task data based on hierarchy context and asset name + hierarchy_context = instance.context.data.get("hierarchyContext") + asset_name = instance.data.get("asset") + if not hierarchy_context or not asset_name: + return + + project_name = instance.context.data["projectName"] + # OpenPype approach vs AYON approach + if "/" not in asset_name: + tasks_info = self._find_tasks_info_in_hierarchy( + hierarchy_context, asset_name + ) + else: + current_data = hierarchy_context.get(project_name, {}) + for key in asset_name.split("/"): + if key: + current_data = current_data.get("childs", {}).get(key, {}) + tasks_info = current_data.get("tasks", {}) + + task_info = tasks_info.get(task_name, {}) + task_type = task_info.get("type") + task_code = ( + project_task_types + .get(task_type, {}) + .get("short_name") + ) + anatomy_data["task"] = { + "name": task_name, + "type": task_type, + "short": task_code + } + + def _get_task_data_from_asset( + self, asset_doc, task_name, project_task_types + ): + """ + + Args: + asset_doc (Union[dict[str, Any], None]): Asset document. + task_name (Union[str, None]): Task name. + project_task_types (dict[str, dict[str, Any]]): Project task + types. + + Returns: + Union[dict[str, str], None]: Task data or None if not found. + """ + + if not asset_doc or not task_name: + return None + + asset_tasks = asset_doc["data"]["tasks"] + task_type = asset_tasks.get(task_name, {}).get("type") + task_code = ( + project_task_types + .get(task_type, {}) + .get("short_name") + ) + return { + "name": task_name, + "type": task_type, + "short": task_code + } + + def _find_tasks_info_in_hierarchy(self, hierarchy_context, asset_name): + """Find tasks info for an asset in editorial hierarchy. + + Args: + hierarchy_context (dict[str, Any]): Editorial hierarchy context. + asset_name (str): Asset name. + + Returns: + dict[str, dict[str, Any]]: Tasks info by name. + """ + + hierarchy_queue = collections.deque() + hierarchy_queue.append(hierarchy_context) + while hierarchy_queue: + item = hierarchy_context.popleft() + if asset_name in item: + return item[asset_name].get("tasks") or {} + + for subitem in item.values(): + hierarchy_queue.extend(subitem.get("childs") or []) + return {} diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index c8b67a3d05..6a871124f1 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -79,19 +79,6 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "representation": "TEMP" }) - # Add fill keys for editorial publishing creating new entity - # TODO handle in editorial plugin - if instance.data.get("newAssetPublishing"): - if "hierarchy" not in template_data: - template_data["hierarchy"] = instance.data["hierarchy"] - - if "asset" not in template_data: - asset_name = instance.data["asset"].split("/")[-1] - template_data["asset"] = asset_name - template_data["folder"] = { - "name": asset_name - } - publish_templates = anatomy.templates_obj["publish"] if "folder" in publish_templates: publish_folder = publish_templates["folder"].format_strict( diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index 401a5d615d..33cbf6d9bf 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -65,7 +65,8 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "files": dst_filename, "stagingDir": dst_staging, "thumbnail": True, - "tags": ["thumbnail"] + "tags": ["thumbnail"], + "outputName": "thumbnail", } # adding representation diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 0edcae060a..8579442625 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -15,6 +15,11 @@ "copy_status": false, "force_sync": false }, + "hooks": { + "InstallPySideToFusion": { + "enabled": true + } + }, "create": { "CreateSaver": { "temp_rendering_path_template": "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}", 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 5177d8bc7c..fbd856b895 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -41,6 +41,29 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "hooks", + "label": "Hooks", + "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "InstallPySideToFusion", + "label": "Install PySide2", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/tools/ayon_loader/models/site_sync.py b/openpype/tools/ayon_loader/models/site_sync.py index 90852b6954..4b7ddee481 100644 --- a/openpype/tools/ayon_loader/models/site_sync.py +++ b/openpype/tools/ayon_loader/models/site_sync.py @@ -140,12 +140,10 @@ class SiteSyncModel: Union[dict[str, Any], None]: Site icon definition. """ - if not project_name: + if not project_name or not self.is_site_sync_enabled(project_name): return None - active_site = self.get_active_site(project_name) - provider = self._get_provider_for_site(project_name, active_site) - return self._get_provider_icon(provider) + return self._get_site_icon_def(project_name, active_site) def get_remote_site_icon_def(self, project_name): """Remote site icon definition. @@ -160,7 +158,14 @@ class SiteSyncModel: if not project_name or not self.is_site_sync_enabled(project_name): return None remote_site = self.get_remote_site(project_name) - provider = self._get_provider_for_site(project_name, remote_site) + return self._get_site_icon_def(project_name, remote_site) + + def _get_site_icon_def(self, project_name, site_name): + # use different icon for studio even if provider is 'local_drive' + if site_name == self._site_sync_addon.DEFAULT_SITE: + provider = "studio" + else: + provider = self._get_provider_for_site(project_name, site_name) return self._get_provider_icon(provider) def get_version_sync_availability(self, project_name, version_ids): diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py index 6111d7e43b..3b063ff72e 100644 --- a/openpype/tools/ayon_sceneinventory/control.py +++ b/openpype/tools/ayon_sceneinventory/control.py @@ -84,9 +84,9 @@ class SceneInventoryController: def get_containers(self): host = self._host if isinstance(host, ILoadHost): - return host.get_containers() + return list(host.get_containers()) elif hasattr(host, "ls"): - return host.ls() + return list(host.ls()) return [] # Site Sync methods diff --git a/openpype/tools/ayon_sceneinventory/model.py b/openpype/tools/ayon_sceneinventory/model.py index 16924b0a7e..f4450f0ac3 100644 --- a/openpype/tools/ayon_sceneinventory/model.py +++ b/openpype/tools/ayon_sceneinventory/model.py @@ -23,6 +23,7 @@ from openpype.pipeline import ( ) from openpype.style import get_default_entity_icon_color from openpype.tools.utils.models import TreeModel, Item +from openpype.tools.ayon_utils.widgets import get_qt_icon def walk_hierarchy(node): @@ -71,8 +72,8 @@ class InventoryModel(TreeModel): site_icons = self._controller.get_site_provider_icons() self._site_icons = { - provider: QtGui.QIcon(icon_path) - for provider, icon_path in site_icons.items() + provider: get_qt_icon(icon_def) + for provider, icon_def in site_icons.items() } def outdated(self, item): diff --git a/openpype/tools/ayon_sceneinventory/models/site_sync.py b/openpype/tools/ayon_sceneinventory/models/site_sync.py index 1297137cb0..bd65ad1778 100644 --- a/openpype/tools/ayon_sceneinventory/models/site_sync.py +++ b/openpype/tools/ayon_sceneinventory/models/site_sync.py @@ -42,8 +42,8 @@ class SiteSyncModel: if not self.is_sync_server_enabled(): return {} - site_sync = self._get_sync_server_module() - return site_sync.get_site_icons() + site_sync_addon = self._get_sync_server_module() + return site_sync_addon.get_site_icons() def get_sites_information(self): return { @@ -150,23 +150,23 @@ class SiteSyncModel: 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: + if self.is_sync_server_enabled(): + site_sync = self._get_sync_server_module() 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( + active_site_provider = site_sync.get_provider_for_site( project_name, active_site ) if remote_site != "studio": - remote_site_provider = site_sync.get_active_provider( + remote_site_provider = site_sync.get_provider_for_site( project_name, remote_site ) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 3504b419b4..37b958c1c7 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -18,10 +18,11 @@ class ScreenMarquee(QtWidgets.QDialog): super(ScreenMarquee, self).__init__(parent=parent) self.setWindowFlags( - QtCore.Qt.FramelessWindowHint + QtCore.Qt.Window + | QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint | QtCore.Qt.CustomizeWindowHint - | QtCore.Qt.Tool) + ) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setCursor(QtCore.Qt.CrossCursor) self.setMouseTracking(True) @@ -210,6 +211,9 @@ class ScreenMarquee(QtWidgets.QDialog): """ tool = cls() + # Activate so Escape event is not ignored. + tool.setWindowState(QtCore.Qt.WindowActive) + # Exec dialog and return captured pixmap. tool.exec_() return tool.get_captured_pixmap() diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index b3138c3f45..5dd6998b24 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -42,7 +42,7 @@ from .widgets import ( ) -class PublisherWindow(QtWidgets.QDialog): +class PublisherWindow(QtWidgets.QWidget): """Main window of publisher.""" default_width = 1300 default_height = 800 @@ -50,7 +50,7 @@ class PublisherWindow(QtWidgets.QDialog): publish_footer_spacer = 2 def __init__(self, parent=None, controller=None, reset_on_show=None): - super(PublisherWindow, self).__init__(parent) + super(PublisherWindow, self).__init__() self.setObjectName("PublishWindow") @@ -64,17 +64,12 @@ class PublisherWindow(QtWidgets.QDialog): if reset_on_show is None: reset_on_show = True - if parent is None: - on_top_flag = QtCore.Qt.WindowStaysOnTopHint - else: - on_top_flag = QtCore.Qt.Dialog - self.setWindowFlags( - QtCore.Qt.WindowTitleHint + QtCore.Qt.Window + | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowMaximizeButtonHint | QtCore.Qt.WindowMinimizeButtonHint | QtCore.Qt.WindowCloseButtonHint - | on_top_flag ) if controller is None: @@ -189,7 +184,7 @@ class PublisherWindow(QtWidgets.QDialog): controller, content_stacked_widget ) - report_widget = ReportPageWidget(controller, parent) + report_widget = ReportPageWidget(controller, content_stacked_widget) # Details - Publish details publish_details_widget = PublishReportViewerWidget( @@ -299,6 +294,12 @@ class PublisherWindow(QtWidgets.QDialog): controller.event_system.add_callback( "publish.process.stopped", self._on_publish_stop ) + controller.event_system.add_callback( + "publish.process.instance.changed", self._on_instance_change + ) + controller.event_system.add_callback( + "publish.process.plugin.changed", self._on_plugin_change + ) controller.event_system.add_callback( "show.card.message", self._on_overlay_message ) @@ -557,6 +558,18 @@ class PublisherWindow(QtWidgets.QDialog): self._reset_on_show = False self.reset() + def _make_sure_on_top(self): + """Raise window to top and activate it. + + This may not work for some DCCs without Qt. + """ + + if not self._window_is_visible: + self.show() + + self.setWindowState(QtCore.Qt.WindowActive) + self.raise_() + def _checks_before_save(self, explicit_save): """Save of changes may trigger some issues. @@ -869,6 +882,12 @@ class PublisherWindow(QtWidgets.QDialog): if self._is_on_create_tab(): self._go_to_publish_tab() + def _on_instance_change(self): + self._make_sure_on_top() + + def _on_plugin_change(self): + self._make_sure_on_top() + def _on_publish_validated_change(self, event): if event["value"]: self._validate_btn.setEnabled(False) @@ -879,6 +898,7 @@ class PublisherWindow(QtWidgets.QDialog): self._comment_input.setText("") def _on_publish_stop(self): + self._make_sure_on_top() self._set_publish_overlay_visibility(False) self._reset_btn.setEnabled(True) self._stop_btn.setEnabled(False) diff --git a/server_addon/fusion/server/settings.py b/server_addon/fusion/server/settings.py index 1bc12773d2..21189b390e 100644 --- a/server_addon/fusion/server/settings.py +++ b/server_addon/fusion/server/settings.py @@ -25,16 +25,6 @@ def _create_saver_instance_attributes_enum(): ] -def _image_format_enum(): - return [ - {"value": "exr", "label": "exr"}, - {"value": "tga", "label": "tga"}, - {"value": "png", "label": "png"}, - {"value": "tif", "label": "tif"}, - {"value": "jpg", "label": "jpg"}, - ] - - class CreateSaverPluginModel(BaseSettingsModel): _isGroup = True temp_rendering_path_template: str = Field( @@ -49,9 +39,23 @@ class CreateSaverPluginModel(BaseSettingsModel): enum_resolver=_create_saver_instance_attributes_enum, title="Instance attributes" ) - image_format: str = Field( - enum_resolver=_image_format_enum, - title="Output Image Format" + output_formats: list[str] = Field( + default_factory=list, + title="Output formats" + ) + + +class HookOptionalModel(BaseSettingsModel): + enabled: bool = Field( + True, + title="Enabled" + ) + + +class HooksModel(BaseSettingsModel): + InstallPySideToFusion: HookOptionalModel = Field( + default_factory=HookOptionalModel, + title="Install PySide2" ) @@ -71,6 +75,10 @@ class FusionSettings(BaseSettingsModel): default_factory=CopyFusionSettingsModel, title="Local Fusion profile settings" ) + hooks: HooksModel = Field( + default_factory=HooksModel, + title="Hooks" + ) create: CreatPluginsModel = Field( default_factory=CreatPluginsModel, title="Creator plugins" @@ -93,6 +101,11 @@ DEFAULT_VALUES = { "copy_status": False, "force_sync": False }, + "hooks": { + "InstallPySideToFusion": { + "enabled": True + } + }, "create": { "CreateSaver": { "temp_rendering_path_template": "{workdir}/renders/fusion/{product[name]}/{product[name]}.{frame}.{ext}", @@ -104,7 +117,15 @@ DEFAULT_VALUES = { "reviewable", "farm_rendering" ], - "image_format": "exr" + "output_formats": [ + "exr", + "jpg", + "jpeg", + "jpg", + "tiff", + "png", + "tga" + ] } } } diff --git a/server_addon/fusion/server/version.py b/server_addon/fusion/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/fusion/server/version.py +++ b/server_addon/fusion/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml index d8de9d4d96..b5978f0498 100644 --- a/server_addon/openpype/client/pyproject.toml +++ b/server_addon/openpype/client/pyproject.toml @@ -7,15 +7,16 @@ python = ">=3.9.1,<3.10" aiohttp_json_rpc = "*" # TVPaint server aiohttp-middlewares = "^2.0.0" wsrpc_aiohttp = "^3.1.1" # websocket server +Click = "^8" clique = "1.6.*" jsonschema = "^2.6.0" pymongo = "^3.11.2" log4mongo = "^1.7" pyblish-base = "^1.8.11" pynput = "^1.7.2" # Timers manager - TODO remove -"Qt.py" = "^1.3.3" -qtawesome = "0.7.3" speedcopy = "^2.1" +six = "^1.15" +qtawesome = "0.7.3" [ayon.runtimeDependencies] OpenTimelineIO = "0.14.1"