diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 2193e96cb1..265d79b53e 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -591,22 +591,6 @@ def create_instances_for_aov( # AOV product of its own. log = Logger.get_logger("farm_publishing") - additional_color_data = { - "renderProducts": instance.data["renderProducts"], - "colorspaceConfig": instance.data["colorspaceConfig"], - "display": instance.data["colorspaceDisplay"], - "view": instance.data["colorspaceView"] - } - - # Get templated path from absolute config path. - anatomy = instance.context.data["anatomy"] - colorspace_template = instance.data["colorspaceConfig"] - try: - additional_color_data["colorspaceTemplate"] = remap_source( - colorspace_template, anatomy) - except ValueError as e: - log.warning(e) - additional_color_data["colorspaceTemplate"] = colorspace_template # if there are product to attach to and more than one AOV, # we cannot proceed. @@ -618,6 +602,29 @@ def create_instances_for_aov( "attaching multiple AOVs or renderable cameras to " "product is not supported yet.") + additional_data = { + "renderProducts": instance.data["renderProducts"], + } + + # Collect color management data if present + colorspace_config = instance.data.get("colorspaceConfig") + if colorspace_config: + additional_data.update({ + "colorspaceConfig": colorspace_config, + # Display/View are optional + "display": instance.data.get("colorspaceDisplay"), + "view": instance.data.get("colorspaceView") + }) + + # Get templated path from absolute config path. + anatomy = instance.context.data["anatomy"] + try: + additional_data["colorspaceTemplate"] = remap_source( + colorspace_config, anatomy) + except ValueError as e: + log.warning(e) + additional_data["colorspaceTemplate"] = colorspace_config + # create instances for every AOV we found in expected files. # NOTE: this is done for every AOV and every render camera (if # there are multiple renderable cameras in scene) @@ -625,7 +632,7 @@ def create_instances_for_aov( instance, skeleton, aov_filter, - additional_color_data, + additional_data, skip_integration_repre_list, do_not_add_review, frames_to_render @@ -936,16 +943,28 @@ def _create_instances_for_aov( "stagingDir": staging_dir, "fps": new_instance.get("fps"), "tags": ["review"] if preview else [], - "colorspaceData": { + } + + if colorspace and additional_data["colorspaceConfig"]: + # Only apply colorspace data if the image has a colorspace + colorspace_data: dict = { "colorspace": colorspace, "config": { "path": additional_data["colorspaceConfig"], "template": additional_data["colorspaceTemplate"] }, - "display": additional_data["display"], - "view": additional_data["view"] } - } + # Display/View are optional + display = additional_data.get("display") + if display: + colorspace_data["display"] = display + view = additional_data.get("view") + if view: + colorspace_data["view"] = view + + rep["colorspaceData"] = colorspace_data + else: + log.debug("No colorspace data for representation: {}".format(rep)) # support conversion from tiled to scanline if instance.data.get("convertToScanline"): diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2380b465ed..a1a98a2bf0 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -45,6 +45,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=32, group_label="Copy file path", data={ "representation_id": repre_id, @@ -60,6 +61,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=33, group_label="Copy file", data={ "representation_id": repre_id, diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 7499650cbe..ce67df1c0c 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -66,7 +66,7 @@ class DeleteOldVersions(LoaderActionPlugin): ), LoaderActionItem( label="Calculate Versions size", - order=30, + order=34, data={ "product_ids": list(product_ids), "action": "calculate-versions-size", diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 9b5a6fec20..d226786bc2 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -1,8 +1,10 @@ import os import sys import subprocess +import platform import collections -from typing import Optional, Any +import ctypes +from typing import Optional, Any, Callable from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.actions import ( @@ -13,6 +15,240 @@ from ayon_core.pipeline.actions import ( ) +WINDOWS_USER_REG_PATH = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts" + r"\{ext}\UserChoice" +) + + +class _Cache: + """Cache extensions information. + + Notes: + The cache is cleared when loader tool is refreshed so it might be + moved to other place which is not cleared on refresh. + + """ + supported_exts: set[str] = set() + unsupported_exts: set[str] = set() + + @classmethod + def is_supported(cls, ext: str) -> bool: + return ext in cls.supported_exts + + @classmethod + def already_checked(cls, ext: str) -> bool: + return ( + ext in cls.supported_exts + or ext in cls.unsupported_exts + ) + + @classmethod + def set_ext_support(cls, ext: str, supported: bool) -> None: + if supported: + cls.supported_exts.add(ext) + else: + cls.unsupported_exts.add(ext) + + +def _extension_has_assigned_app_windows(ext: str) -> bool: + import winreg + progid = None + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + WINDOWS_USER_REG_PATH.format(ext=ext), + ) as k: + progid, _ = winreg.QueryValueEx(k, "ProgId") + except OSError: + pass + + if progid: + return True + + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k: + progid = winreg.QueryValueEx(k, None)[0] + except OSError: + pass + return bool(progid) + + +def _linux_find_desktop_file(desktop: str) -> Optional[str]: + for dirpath in ( + os.path.expanduser("~/.local/share/applications"), + "/usr/share/applications", + "/usr/local/share/applications", + ): + path = os.path.join(dirpath, desktop) + if os.path.isfile(path): + return path + return None + + +def _extension_has_assigned_app_linux(ext: str) -> bool: + import mimetypes + + mime, _ = mimetypes.guess_type(f"file{ext}") + if not mime: + return False + + try: + # xdg-mime query default + desktop = subprocess.check_output( + ["xdg-mime", "query", "default", mime], + text=True + ).strip() or None + except Exception: + desktop = None + + if not desktop: + return False + + desktop_path = _linux_find_desktop_file(desktop) + if not desktop_path: + return False + if desktop_path and os.path.isfile(desktop_path): + return True + return False + + +def _extension_has_assigned_app_macos(ext: str) -> bool: + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via + # ctypes. + # Steps: ext -> UTI -> default handler bundle id for role 'all'. + cf = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + ls = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/Frameworks" + "/LaunchServices.framework/LaunchServices" + ) + + # CFType/CFString helpers + CFStringRef = ctypes.c_void_p + CFAllocatorRef = ctypes.c_void_p + CFIndex = ctypes.c_long + + kCFStringEncodingUTF8 = 0x08000100 + + cf.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32 + ] + cf.CFStringCreateWithCString.restype = CFStringRef + + cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] + cf.CFStringGetCStringPtr.restype = ctypes.c_char_p + + cf.CFStringGetCString.argtypes = [ + CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32 + ] + cf.CFStringGetCString.restype = ctypes.c_bool + + cf.CFRelease.argtypes = [ctypes.c_void_p] + cf.CFRelease.restype = None + + try: + UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/CoreServices" + ).UTTypeCreatePreferredIdentifierForTag + except OSError: + # Fallback path (older systems) + UTTypeCreatePreferredIdentifierForTag = ( + ls.UTTypeCreatePreferredIdentifierForTag + ) + UTTypeCreatePreferredIdentifierForTag.argtypes = [ + CFStringRef, CFStringRef, CFStringRef + ] + UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef + + LSRolesMask = ctypes.c_uint + kLSRolesAll = 0xFFFFFFFF + ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [ + CFStringRef, LSRolesMask + ] + ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef + + def cfstr(py_s: str) -> CFStringRef: + return cf.CFStringCreateWithCString( + None, py_s.encode("utf-8"), kCFStringEncodingUTF8 + ) + + def to_pystr(cf_s: CFStringRef) -> Optional[str]: + if not cf_s: + return None + # Try fast pointer + ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8) + if ptr: + return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8") + + # Fallback buffer + buf_size = 1024 + buf = ctypes.create_string_buffer(buf_size) + ok = cf.CFStringGetCString( + cf_s, buf, buf_size, kCFStringEncodingUTF8 + ) + if ok: + return buf.value.decode("utf-8") + return None + + # Convert extension (without dot) to UTI + tag_class = cfstr("public.filename-extension") + tag_value = cfstr(ext.lstrip(".")) + + uti_ref = UTTypeCreatePreferredIdentifierForTag( + tag_class, tag_value, None + ) + + # Clean up temporary CFStrings + for ref in (tag_class, tag_value): + if ref: + cf.CFRelease(ref) + + bundle_id = None + if uti_ref: + # Get default handler for the UTI + default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType( + uti_ref, kLSRolesAll + ) + bundle_id = to_pystr(default_bundle_ref) + if default_bundle_ref: + cf.CFRelease(default_bundle_ref) + cf.CFRelease(uti_ref) + return bundle_id is not None + + +def _filter_supported_exts( + extensions: set[str], test_func: Callable +) -> set[str]: + filtered_exs: set[str] = set() + for ext in extensions: + if not _Cache.already_checked(ext): + _Cache.set_ext_support(ext, test_func(ext)) + if _Cache.is_supported(ext): + filtered_exs.add(ext) + return filtered_exs + + +def filter_supported_exts(extensions: set[str]) -> set[str]: + if not extensions: + return set() + platform_name = platform.system().lower() + if platform_name == "windows": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_windows + ) + if platform_name == "linux": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_linux + ) + if platform_name == "darwin": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_macos + ) + return set() + + def open_file(filepath: str) -> None: """Open file with system default executable""" if sys.platform.startswith("darwin"): @@ -27,8 +263,6 @@ class OpenFileAction(LoaderActionPlugin): """Open Image Sequence or Video with system default""" identifier = "core.open-file" - product_types = {"render2d"} - def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -46,48 +280,43 @@ class OpenFileAction(LoaderActionPlugin): if not repres: return [] - repre_ids = {repre["id"] for repre in repres} - versions = selection.entities.get_representations_versions( - repre_ids - ) - product_ids = {version["productId"] for version in versions} - products = selection.entities.get_products(product_ids) - filtered_product_ids = { - product["id"] - for product in products - if product["productType"] in self.product_types - } - if not filtered_product_ids: + repres_by_ext = collections.defaultdict(list) + for repre in repres: + repre_context = repre.get("context") + if not repre_context: + continue + ext = repre_context.get("ext") + if not ext: + path = repre["attrib"].get("path") + if path: + ext = os.path.splitext(path)[1] + + if ext: + ext = ext.lower() + if not ext.startswith("."): + ext = f".{ext}" + repres_by_ext[ext.lower()].append(repre) + + if not repres_by_ext: return [] - versions_by_product_id = collections.defaultdict(list) - for version in versions: - versions_by_product_id[version["productId"]].append(version) - - repres_by_version_ids = collections.defaultdict(list) - for repre in repres: - repres_by_version_ids[repre["versionId"]].append(repre) - - filtered_repres = [] - for product_id in filtered_product_ids: - for version in versions_by_product_id[product_id]: - for repre in repres_by_version_ids[version["id"]]: - filtered_repres.append(repre) + filtered_exts = filter_supported_exts(set(repres_by_ext)) repre_ids_by_name = collections.defaultdict(set) - for repre in filtered_repres: - repre_ids_by_name[repre["name"]].add(repre["id"]) + for ext in filtered_exts: + for repre in repres_by_ext[ext]: + repre_ids_by_name[repre["name"]].add(repre["id"]) return [ LoaderActionItem( label=repre_name, group_label="Open file", - order=-10, + order=30, data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", - "name": "play_circle", - "color": "#FFA500", + "name": "file_open", + "color": "#ffffff", } ) for repre_name, repre_ids in repre_ids_by_name.items() @@ -122,6 +351,7 @@ class OpenFileAction(LoaderActionPlugin): ) self.log.info(f"Opening: {path}") + open_file(path) return LoaderActionResult( diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index ca95b1ff1a..a9abd56584 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -212,6 +212,11 @@ class ContextCardWidget(CardWidget): icon_widget.setObjectName("ProductTypeIconLabel") label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self) + # HTML text will cause that label start catch mouse clicks + # - disabling with changing interaction flag + label_widget.setTextInteractionFlags( + QtCore.Qt.NoTextInteraction + ) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5)