Merge branch 'develop' into enhancement/store-host-name-to-version

This commit is contained in:
Jakub Trllo 2025-11-18 12:02:02 +01:00 committed by GitHub
commit 89dc8502e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 311 additions and 55 deletions

View file

@ -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"):

View file

@ -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,

View file

@ -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",

View file

@ -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 <mime>
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(

View file

@ -212,6 +212,11 @@ class ContextCardWidget(CardWidget):
icon_widget.setObjectName("ProductTypeIconLabel")
label_widget = QtWidgets.QLabel(f"<span>{CONTEXT_LABEL}</span>", 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)