Merge branch 'enhancement/AY-6586_Thumbnail_presets' of https://github.com/ynput/ayon-core into enhancement/AY-6586_Thumbnail_presets

This commit is contained in:
Petr Kalis 2025-12-12 11:03:01 +01:00
commit 8fe830f5de
12 changed files with 273 additions and 48 deletions

View file

@ -1234,17 +1234,11 @@ def oiio_color_convert(
if source_view and source_display:
color_convert_args = None
ocio_display_args = None
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if target_colorspace:
# This is a two-step conversion process since there's no direct
# display/view to colorspace command
# This could be a config parameter or determined from OCIO config
# Use temporarty role space 'scene_linear'
# Use temporary role space 'scene_linear'
color_convert_args = ("scene_linear", target_colorspace)
elif source_display != target_display or source_view != target_view:
# Complete display/view pair conversion
@ -1256,6 +1250,15 @@ def oiio_color_convert(
" No color conversion needed."
)
if color_convert_args or ocio_display_args:
# Invert source display/view so that we can go from there to the
# target colorspace or display/view
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if color_convert_args:
# Use colorconvert for colorspace target
oiio_cmd.extend([

View file

@ -192,7 +192,9 @@ class HelpContent:
self.detail = detail
def load_help_content_from_filepath(filepath):
def load_help_content_from_filepath(
filepath: str
) -> dict[str, dict[str, HelpContent]]:
"""Load help content from xml file.
Xml file may contain errors and warnings.
"""
@ -227,15 +229,20 @@ def load_help_content_from_filepath(filepath):
return output
def load_help_content_from_plugin(plugin):
def load_help_content_from_plugin(
plugin: pyblish.api.Plugin,
help_filename: Optional[str] = None,
) -> dict[str, dict[str, HelpContent]]:
cls = plugin
if not inspect.isclass(plugin):
cls = plugin.__class__
plugin_filepath = inspect.getfile(cls)
plugin_dir = os.path.dirname(plugin_filepath)
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", filename)
if help_filename is None:
basename = os.path.splitext(os.path.basename(plugin_filepath))[0]
help_filename = basename + ".xml"
filepath = os.path.join(plugin_dir, "help", help_filename)
return load_help_content_from_filepath(filepath)

View file

@ -1,7 +1,7 @@
import inspect
from abc import ABCMeta
import typing
from typing import Optional
from typing import Optional, Any
import pyblish.api
import pyblish.logic
@ -82,22 +82,51 @@ class PublishValidationError(PublishError):
class PublishXmlValidationError(PublishValidationError):
"""Raise an error from a dedicated xml file.
Can be useful to have one xml file with different possible messages that
helps to avoid flood code with dedicated artist messages.
XML files should live relative to the plugin file location:
'{plugin dir}/help/some_plugin.xml'.
Args:
plugin (pyblish.api.Plugin): Plugin that raised an error. Is used
to get path to xml file.
message (str): Exception message, can be technical, is used for
console output.
key (Optional[str]): XML file can contain multiple error messages, key
is used to get one of them. By default is used 'main'.
formatting_data (Optional[dict[str, Any]): Error message can have
variables to fill.
help_filename (Optional[str]): Name of xml file with messages. By
default, is used filename where plugin lives with .xml extension.
"""
def __init__(
self, plugin, message, key=None, formatting_data=None
):
self,
plugin: pyblish.api.Plugin,
message: str,
key: Optional[str] = None,
formatting_data: Optional[dict[str, Any]] = None,
help_filename: Optional[str] = None,
) -> None:
if key is None:
key = "main"
if not formatting_data:
formatting_data = {}
result = load_help_content_from_plugin(plugin)
result = load_help_content_from_plugin(plugin, help_filename)
content_obj = result["errors"][key]
description = content_obj.description.format(**formatting_data)
detail = content_obj.detail
if detail:
detail = detail.format(**formatting_data)
super(PublishXmlValidationError, self).__init__(
message, content_obj.title, description, detail
super().__init__(
message,
content_obj.title,
description,
detail
)

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>{upload_type} upload timed out</title>
<description>
## {upload_type} upload failed after retries
The connection to the AYON server timed out while uploading a file.
### How to resolve?
1. Try publishing again. Intermittent network hiccups often resolve on retry.
2. Ensure your network/VPN is stable and large uploads are allowed.
3. If it keeps failing, try again later or contact your admin.
<pre>File: {file}
Error: {error}</pre>
</description>
</error>
</root>

View file

@ -1,11 +1,17 @@
import os
import time
import pyblish.api
import ayon_api
from ayon_api import TransferProgress
from ayon_api.server_api import RequestTypes
import pyblish.api
from ayon_core.lib import get_media_mime_type
from ayon_core.pipeline.publish import get_publish_repre_path
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import (
PublishXmlValidationError,
get_publish_repre_path,
)
import requests.exceptions
class IntegrateAYONReview(pyblish.api.InstancePlugin):
@ -44,7 +50,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
if "webreview" not in repre_tags:
continue
# exclude representations with are going to be published on farm
# exclude representations going to be published on farm
if "publish_on_farm" in repre_tags:
continue
@ -75,18 +81,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
f"/projects/{project_name}"
f"/versions/{version_id}/reviewables{query}"
)
filename = os.path.basename(repre_path)
# Upload the reviewable
self.log.info(f"Uploading reviewable '{label or filename}' ...")
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
self.log.info(f"Uploading reviewable {repre_path}")
ayon_con.upload_file(
# Upload with retries and clear help if it keeps failing
self._upload_with_retries(
ayon_con,
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
content_type,
)
def _get_review_label(self, repre, uploaded_labels):
@ -100,3 +101,74 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin):
idx += 1
label = f"{orig_label}_{idx}"
return label
def _upload_with_retries(
self,
ayon_con: ayon_api.ServerAPI,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
filename = os.path.basename(repre_path)
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of reviewable timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Review",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -24,11 +24,16 @@
import os
import collections
import time
import pyblish.api
import ayon_api
from ayon_api import RequestTypes
from ayon_api import RequestTypes, TransferProgress
from ayon_api.operations import OperationsSession
import pyblish.api
import requests
from ayon_core.lib import get_media_mime_type, format_file_size
from ayon_core.pipeline.publish import PublishXmlValidationError
InstanceFilterResult = collections.namedtuple(
@ -164,25 +169,17 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
return os.path.normpath(filled_path)
def _create_thumbnail(self, project_name: str, src_filepath: str) -> str:
"""Upload thumbnail to AYON and return its id.
This is temporary fix of 'create_thumbnail' function in ayon_api to
fix jpeg mime type.
"""
mime_type = None
with open(src_filepath, "rb") as stream:
if b"\xff\xd8\xff" == stream.read(3):
mime_type = "image/jpeg"
"""Upload thumbnail to AYON and return its id."""
mime_type = get_media_mime_type(src_filepath)
if mime_type is None:
return ayon_api.create_thumbnail(project_name, src_filepath)
return ayon_api.create_thumbnail(
project_name, src_filepath
)
response = ayon_api.upload_file(
response = self._upload_with_retries(
f"projects/{project_name}/thumbnails",
src_filepath,
request_type=RequestTypes.post,
headers={"Content-Type": mime_type},
mime_type,
)
response.raise_for_status()
return response.json()["id"]
@ -248,3 +245,71 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
or instance.data.get("name")
or "N/A"
)
def _upload_with_retries(
self,
endpoint: str,
repre_path: str,
content_type: str,
):
"""Upload file with simple retries."""
ayon_con = ayon_api.get_server_api_connection()
headers = ayon_con.get_headers(content_type)
max_retries = ayon_con.get_default_max_retries()
# Retries are already implemented in 'ayon_api.upload_file'
# - added in ayon api 1.2.7
if hasattr(TransferProgress, "get_attempt"):
max_retries = 1
size = os.path.getsize(repre_path)
self.log.info(
f"Uploading '{repre_path}' (size: {format_file_size(size)})"
)
# How long to sleep before next attempt
wait_time = 1
last_error = None
for attempt in range(max_retries):
attempt += 1
start = time.time()
try:
output = ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
self.log.debug(f"Uploaded in {time.time() - start}s.")
return output
except (
requests.exceptions.Timeout,
requests.exceptions.ConnectionError
) as exc:
# Log and retry with backoff if attempts remain
if attempt >= max_retries:
last_error = exc
break
self.log.warning(
f"Review upload failed ({attempt}/{max_retries})"
f" after {time.time() - start}s."
f" Retrying in {wait_time}s...",
exc_info=True,
)
time.sleep(wait_time)
# Exhausted retries - raise a user-friendly validation error with help
raise PublishXmlValidationError(
self,
(
"Upload of thumbnail timed out or failed after multiple"
" attempts. Please try publishing again."
),
formatting_data={
"upload_type": "Thumbnail",
"file": repre_path,
"error": str(last_error),
},
help_filename="upload_file.xml",
)

View file

@ -112,6 +112,7 @@ class HierarchyPage(QtWidgets.QWidget):
self._is_visible = False
self._controller = controller
self._filters_widget = filters_widget
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._folders_widget = folders_widget
@ -136,6 +137,10 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -155,6 +160,7 @@ class HierarchyPage(QtWidgets.QWidget):
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -527,6 +527,10 @@ class LoaderWindow(QtWidgets.QWidget):
if not self._refresh_handler.project_refreshed:
self._projects_combobox.refresh()
self._update_filters()
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_load_finished(self, event):
error_info = event["error_info"]

View file

@ -221,6 +221,7 @@ class CreateContextWidget(QtWidgets.QWidget):
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
self._filters_widget = filters_widget
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -290,6 +291,10 @@ class CreateContextWidget(QtWidgets.QWidget):
self._hierarchy_controller.set_expected_selection(
self._last_project_name, folder_id, task_name
)
# Update my tasks
self._on_my_tasks_change(
self._filters_widget.is_my_tasks_checked()
)
def _clear_selection(self):
self._folders_widget.set_selected_folder(None)

View file

@ -113,6 +113,7 @@ class FoldersDialog(QtWidgets.QDialog):
self._soft_reset_enabled = False
self._folders_widget.set_project_name(self._project_name)
self._on_my_tasks_change(self._filters_widget.is_my_tasks_checked())
def get_selected_folder_path(self):
"""Get selected folder path."""

View file

@ -834,6 +834,12 @@ class FoldersFiltersWidget(QtWidgets.QWidget):
self._folders_filter_input = folders_filter_input
self._my_tasks_checkbox = my_tasks_checkbox
def is_my_tasks_checked(self) -> bool:
return self._my_tasks_checkbox.isChecked()
def text(self) -> str:
return self._folders_filter_input.text()
def set_text(self, text: str) -> None:
self._folders_filter_input.setText(text)

View file

@ -205,6 +205,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._folders_widget = folder_widget
self._filters_widget = filters_widget
return col_widget
def _create_col_3_widget(self, controller, parent):
@ -343,6 +345,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._project_name = self._controller.get_current_project_name()
self._folders_widget.set_project_name(self._project_name)
# Update my tasks
self._on_my_tasks_checkbox_state_changed(
self._filters_widget.is_my_tasks_checked()
)
def _on_save_as_finished(self, event):
if event["failed"]: