Merge branch 'develop' into enhancement/AY-6198_OCIO-fallback-for-profiles-and-templated-values

This commit is contained in:
Jakub Ježek 2024-09-24 10:27:22 +02:00 committed by GitHub
commit c5cc6cbc82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1209 additions and 281 deletions

View file

@ -51,9 +51,10 @@ from .load import (
)
from .publish import (
KnownPublishError,
PublishError,
PublishValidationError,
PublishXmlValidationError,
KnownPublishError,
AYONPyblishPluginMixin,
OptionalPyblishPluginMixin,
)
@ -162,9 +163,10 @@ __all__ = (
"get_repres_contexts",
# --- Publish ---
"KnownPublishError",
"PublishError",
"PublishValidationError",
"PublishXmlValidationError",
"KnownPublishError",
"AYONPyblishPluginMixin",
"OptionalPyblishPluginMixin",

View file

@ -724,7 +724,7 @@ class CreatedInstance:
value when set to True.
"""
return self._data.get("has_promised_context", False)
return self._transient_data.get("has_promised_context", False)
def data_to_store(self):
"""Collect data that contain json parsable types.

View file

@ -4,17 +4,19 @@ AYON is using `pyblish` for publishing process which is a little bit extented an
## Exceptions
AYON define few specific exceptions that should be used in publish plugins.
### Publish error
Exception `PublishError` can be raised on known error. The message is shown to artist.
- **message** Error message.
- **title** Short description of error (2-5 words). Title can be used for grouping of exceptions per plugin.
- **description** Override of 'message' for UI, you can add markdown and html. By default, is filled with 'message'.
- **detail** Additional detail message that is hidden under collapsed component.
Arguments `title`, `description` and `detail` are optional. Title is filled with generic message "This is not your fault" if is not passed.
### Validation exception
Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception.
Exception `PublishValidationError` 3 arguments:
- **message** Which is not used in UI but for headless publishing.
- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin.
- **description** Detailed description of happened issue where markdown and html can be used.
### Known errors
When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown.
Exception expect same arguments as `PublishError`. Value of `title` is filled with plugin label if is not passed.
## Plugin extension
Publish plugins can be extended by additional logic when inherits from `AYONPyblishPluginMixin` which can be used as mixin (additional inheritance of class).

View file

@ -9,9 +9,11 @@ from .publish_plugins import (
AbstractMetaInstancePlugin,
AbstractMetaContextPlugin,
KnownPublishError,
PublishError,
PublishValidationError,
PublishXmlValidationError,
KnownPublishError,
AYONPyblishPluginMixin,
OptionalPyblishPluginMixin,
@ -61,9 +63,11 @@ __all__ = (
"AbstractMetaInstancePlugin",
"AbstractMetaContextPlugin",
"KnownPublishError",
"PublishError",
"PublishValidationError",
"PublishXmlValidationError",
"KnownPublishError",
"AYONPyblishPluginMixin",
"OptionalPyblishPluginMixin",

View file

@ -25,27 +25,52 @@ class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin):
pass
class PublishValidationError(Exception):
"""Validation error happened during publishing.
class KnownPublishError(Exception):
"""Publishing crashed because of known error.
This exception should be used when validation publishing failed.
Artist can't affect source of the error.
Has additional UI specific attributes that may be handy for artist.
Deprecated:
Please use `PublishError` instead. Marked as deprecated 24/09/02.
"""
pass
class PublishError(Exception):
"""Publishing crashed because of known error.
Message will be shown in UI for artist.
Args:
message(str): Message of error. Short explanation an issue.
title(str): Title showed in UI. All instances are grouped under
single title.
description(str): Detailed description of an error. It is possible
to use Markdown syntax.
"""
message (str): Message of error. Short explanation an issue.
title (Optional[str]): Title showed in UI.
description (Optional[str]): Detailed description of an error.
It is possible to use Markdown syntax.
"""
def __init__(self, message, title=None, description=None, detail=None):
self.message = message
self.title = title
self.description = description or message
self.detail = detail
super(PublishValidationError, self).__init__(message)
super().__init__(message)
class PublishValidationError(PublishError):
"""Validation error happened during publishing.
This exception should be used when validation publishing failed.
Publishing does not stop during validation order if this
exception is raised.
Has additional UI specific attributes that may be handy for artist.
Argument 'title' is used to group errors.
"""
pass
class PublishXmlValidationError(PublishValidationError):
@ -68,15 +93,6 @@ class PublishXmlValidationError(PublishValidationError):
)
class KnownPublishError(Exception):
"""Publishing crashed because of known error.
Message will be shown in UI for artist.
"""
pass
class AYONPyblishPluginMixin:
# TODO
# executable_in_thread = False

View file

@ -0,0 +1,87 @@
import os
import urllib.parse
import webbrowser
from ayon_core.pipeline import LauncherAction
from ayon_core.resources import get_ayon_icon_filepath
import ayon_api
def get_ayon_entity_uri(
project_name,
entity_id,
entity_type,
) -> str:
"""Resolve AYON Entity URI from representation context.
Note:
The representation context is the `get_representation_context` dict
containing the `project`, `folder, `representation` and so forth.
It is not the representation entity `context` key.
Arguments:
project_name (str): The project name.
entity_id (str): The entity UUID.
entity_type (str): The entity type, like "folder" or"task".
Raises:
RuntimeError: Unable to resolve to a single valid URI.
Returns:
str: The AYON entity URI.
"""
response = ayon_api.post(
f"projects/{project_name}/uris",
entityType=entity_type,
ids=[entity_id])
if response.status_code != 200:
raise RuntimeError(
f"Unable to resolve AYON entity URI for '{project_name}' "
f"{entity_type} id '{entity_id}': {response.text}"
)
uris = response.data["uris"]
if len(uris) != 1:
raise RuntimeError(
f"Unable to resolve AYON entity URI for '{project_name}' "
f"{entity_type} id '{entity_id}' to single URI. "
f"Received data: {response.data}"
)
return uris[0]["uri"]
class ShowInAYON(LauncherAction):
"""Open AYON browser page to the current context."""
name = "showinayon"
label = "Show in AYON"
icon = get_ayon_icon_filepath()
order = 999
def process(self, selection, **kwargs):
url = os.environ["AYON_SERVER_URL"]
if selection.is_project_selected:
project_name = selection.project_name
url += f"/projects/{project_name}/browser"
# Specify entity URI if task or folder is select
entity = None
entity_type = None
if selection.is_task_selected:
entity = selection.get_task_entity()
entity_type = "task"
elif selection.is_folder_selected:
entity = selection.get_folder_entity()
entity_type = "folder"
if entity and entity_type:
uri = get_ayon_entity_uri(
project_name,
entity_id=entity["id"],
entity_type=entity_type
)
uri_encoded = urllib.parse.quote_plus(uri)
url += f"?uri={uri_encoded}"
# Open URL in webbrowser
self.log.info(f"Opening URL: {url}")
webbrowser.open_new_tab(url)

View file

@ -0,0 +1,591 @@
import logging
import os
from pathlib import Path
from collections import defaultdict
from qtpy import QtWidgets, QtCore, QtGui
from ayon_api import get_representations
from ayon_core.pipeline import load, Anatomy
from ayon_core import resources, style
from ayon_core.lib.transcoding import (
IMAGE_EXTENSIONS,
get_oiio_info_for_input,
)
from ayon_core.lib import (
get_ffprobe_data,
is_oiio_supported,
)
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.tools.utils import show_message_dialog
OTIO = None
FRAME_SPLITTER = "__frame_splitter__"
def _import_otio():
global OTIO
if OTIO is None:
import opentimelineio
OTIO = opentimelineio
class ExportOTIO(load.ProductLoaderPlugin):
"""Export selected versions to OpenTimelineIO."""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = {"*"}
product_types = {"*"}
tool_names = ["library_loader"]
label = "Export OTIO"
order = 35
icon = "save"
color = "#d8d8d8"
def load(self, contexts, name=None, namespace=None, options=None):
_import_otio()
try:
dialog = ExportOTIOOptionsDialog(contexts, self.log)
dialog.exec_()
except Exception:
self.log.error("Failed to export OTIO.", exc_info=True)
class ExportOTIOOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
def __init__(self, contexts, log=None, parent=None):
# Not all hosts have OpenTimelineIO available.
self.log = log
super().__init__(parent=parent)
self.setWindowTitle("AYON - Export OTIO")
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
self.setWindowIcon(icon)
self.setWindowFlags(
QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowMinimizeButtonHint
)
project_name = contexts[0]["project"]["name"]
versions_by_id = {
context["version"]["id"]: context["version"]
for context in contexts
}
repre_entities = list(get_representations(
project_name, version_ids=set(versions_by_id)
))
version_by_representation_id = {
repre_entity["id"]: versions_by_id[repre_entity["versionId"]]
for repre_entity in repre_entities
}
version_path_by_id = {}
representations_by_version_id = {}
for context in contexts:
version_id = context["version"]["id"]
if version_id in version_path_by_id:
continue
representations_by_version_id[version_id] = []
version_path_by_id[version_id] = "/".join([
context["folder"]["path"],
context["product"]["name"],
context["version"]["name"]
])
for repre_entity in repre_entities:
representations_by_version_id[repre_entity["versionId"]].append(
repre_entity
)
all_representation_names = list(sorted({
repo_entity["name"]
for repo_entity in repre_entities
}))
input_widget = QtWidgets.QWidget(self)
input_layout = QtWidgets.QGridLayout(input_widget)
input_layout.setContentsMargins(8, 8, 8, 8)
row = 0
repres_label = QtWidgets.QLabel("Representations:", input_widget)
input_layout.addWidget(repres_label, row, 0)
repre_name_buttons = []
for idx, name in enumerate(all_representation_names):
repre_name_btn = QtWidgets.QPushButton(name, input_widget)
input_layout.addWidget(
repre_name_btn, row, idx + 1,
alignment=QtCore.Qt.AlignCenter
)
repre_name_btn.clicked.connect(self._toggle_all)
repre_name_buttons.append(repre_name_btn)
row += 1
representation_widgets = defaultdict(list)
items = representations_by_version_id.items()
for version_id, representations in items:
version_path = version_path_by_id[version_id]
label_widget = QtWidgets.QLabel(version_path, input_widget)
input_layout.addWidget(label_widget, row, 0)
repres_by_name = {
repre_entity["name"]: repre_entity
for repre_entity in representations
}
radio_group = QtWidgets.QButtonGroup(input_widget)
for idx, name in enumerate(all_representation_names):
if name in repres_by_name:
widget = QtWidgets.QRadioButton(input_widget)
radio_group.addButton(widget)
representation_widgets[name].append(
{
"widget": widget,
"representation": repres_by_name[name]
}
)
else:
widget = QtWidgets.QLabel("x", input_widget)
input_layout.addWidget(
widget, row, idx + 1, 1, 1,
alignment=QtCore.Qt.AlignCenter
)
row += 1
export_widget = QtWidgets.QWidget(self)
options_widget = QtWidgets.QWidget(export_widget)
uri_label = QtWidgets.QLabel("URI paths:", options_widget)
uri_path_format = QtWidgets.QCheckBox(options_widget)
uri_path_format.setToolTip(
"Use URI paths (file:///) instead of absolute paths. "
"This is useful when the OTIO file will be used on Foundry Hiero."
)
button_output_path = QtWidgets.QPushButton(
"Output Path:", options_widget
)
button_output_path.setToolTip(
"Click to select the output path for the OTIO file."
)
line_edit_output_path = QtWidgets.QLineEdit(
(Path.home() / f"{project_name}.otio").as_posix(),
options_widget
)
options_layout = QtWidgets.QHBoxLayout(options_widget)
options_layout.setContentsMargins(0, 0, 0, 0)
options_layout.addWidget(uri_label)
options_layout.addWidget(uri_path_format)
options_layout.addWidget(button_output_path)
options_layout.addWidget(line_edit_output_path)
button_export = QtWidgets.QPushButton("Export", export_widget)
export_layout = QtWidgets.QVBoxLayout(export_widget)
export_layout.setContentsMargins(0, 0, 0, 0)
export_layout.addWidget(options_widget, 0)
export_layout.addWidget(button_export, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.addWidget(input_widget, 0)
main_layout.addStretch(1)
# TODO add line spacer?
main_layout.addSpacing(30)
main_layout.addWidget(export_widget, 0)
button_export.clicked.connect(self._on_export_click)
button_output_path.clicked.connect(self._set_output_path)
self._project_name = project_name
self._version_path_by_id = version_path_by_id
self._version_by_representation_id = version_by_representation_id
self._representation_widgets = representation_widgets
self._repre_name_buttons = repre_name_buttons
self._uri_path_format = uri_path_format
self._button_output_path = button_output_path
self._line_edit_output_path = line_edit_output_path
self._button_export = button_export
self._first_show = True
def showEvent(self, event):
super().showEvent(event)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
def _toggle_all(self):
representation_name = self.sender().text()
for item in self._representation_widgets[representation_name]:
item["widget"].setChecked(True)
def _set_output_path(self):
file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
None, "Save OTIO file.", "", "OTIO Files (*.otio)"
)
if file_path:
self._line_edit_output_path.setText(file_path)
def _on_export_click(self):
output_path = self._line_edit_output_path.text()
# Validate output path is not empty.
if not output_path:
show_message_dialog(
"Missing output path",
(
"Output path is empty. Please enter a path to export the "
"OTIO file to."
),
level="critical",
parent=self
)
return
# Validate output path ends with .otio.
if not output_path.endswith(".otio"):
show_message_dialog(
"Wrong extension.",
(
"Output path needs to end with \".otio\"."
),
level="critical",
parent=self
)
return
representations = []
for name, items in self._representation_widgets.items():
for item in items:
if item["widget"].isChecked():
representations.append(item["representation"])
anatomy = Anatomy(self._project_name)
clips_data = {}
for representation in representations:
version = self._version_by_representation_id[
representation["id"]
]
name = (
f'{self._version_path_by_id[version["id"]]}'
f'/{representation["name"]}'
).replace("/", "_")
clips_data[name] = {
"representation": representation,
"anatomy": anatomy,
"frames": (
version["attrib"]["frameEnd"]
- version["attrib"]["frameStart"]
),
"framerate": version["attrib"]["fps"],
}
self.export_otio(clips_data, output_path)
# Feedback about success.
show_message_dialog(
"Success!",
"Export was successful.",
level="info",
parent=self
)
self.close()
def create_clip(self, name, clip_data, timeline_framerate):
representation = clip_data["representation"]
anatomy = clip_data["anatomy"]
frames = clip_data["frames"]
framerate = clip_data["framerate"]
# Get path to representation with correct frame number
repre_path = get_representation_path_with_anatomy(
representation, anatomy)
media_start_frame = clip_start_frame = 0
media_framerate = framerate
if file_metadata := get_image_info_metadata(
repre_path, ["timecode", "duration", "framerate"], self.log
):
# get media framerate and convert to float with 3 decimal places
media_framerate = file_metadata["framerate"]
media_framerate = float(f"{media_framerate:.4f}")
framerate = float(f"{timeline_framerate:.4f}")
media_start_frame = self.get_timecode_start_frame(
media_framerate, file_metadata
)
clip_start_frame = self.get_timecode_start_frame(
timeline_framerate, file_metadata
)
if "duration" in file_metadata:
frames = int(float(file_metadata["duration"]) * framerate)
repre_path = Path(repre_path)
first_frame = representation["context"].get("frame")
if first_frame is None:
media_range = OTIO.opentime.TimeRange(
start_time=OTIO.opentime.RationalTime(
media_start_frame, media_framerate
),
duration=OTIO.opentime.RationalTime(
frames, media_framerate),
)
clip_range = OTIO.opentime.TimeRange(
start_time=OTIO.opentime.RationalTime(
clip_start_frame, timeline_framerate
),
duration=OTIO.opentime.RationalTime(
frames, timeline_framerate),
)
# Use 'repre_path' as single file
media_reference = OTIO.schema.ExternalReference(
available_range=media_range,
target_url=self.convert_to_uri_or_posix(repre_path),
)
else:
# This is sequence
repre_files = [
file["path"].format(root=anatomy.roots)
for file in representation["files"]
]
# Change frame in representation context to get path with frame
# splitter.
representation["context"]["frame"] = FRAME_SPLITTER
frame_repre_path = get_representation_path_with_anatomy(
representation, anatomy
)
frame_repre_path = Path(frame_repre_path)
repre_dir, repre_filename = (
frame_repre_path.parent, frame_repre_path.name)
# Get sequence prefix and suffix
file_prefix, file_suffix = repre_filename.split(FRAME_SPLITTER)
# Get frame number from path as string to get frame padding
frame_str = str(repre_path)[len(file_prefix):][:len(file_suffix)]
frame_padding = len(frame_str)
media_range = OTIO.opentime.TimeRange(
start_time=OTIO.opentime.RationalTime(
media_start_frame, media_framerate
),
duration=OTIO.opentime.RationalTime(
len(repre_files), media_framerate
),
)
clip_range = OTIO.opentime.TimeRange(
start_time=OTIO.opentime.RationalTime(
clip_start_frame, timeline_framerate
),
duration=OTIO.opentime.RationalTime(
len(repre_files), timeline_framerate
),
)
media_reference = OTIO.schema.ImageSequenceReference(
available_range=media_range,
start_frame=int(first_frame),
frame_step=1,
rate=framerate,
target_url_base=f"{self.convert_to_uri_or_posix(repre_dir)}/",
name_prefix=file_prefix,
name_suffix=file_suffix,
frame_zero_padding=frame_padding,
)
return OTIO.schema.Clip(
name=name, media_reference=media_reference, source_range=clip_range
)
def convert_to_uri_or_posix(self, path: Path) -> str:
"""Convert path to URI or Posix path.
Args:
path (Path): Path to convert.
Returns:
str: Path as URI or Posix path.
"""
if self._uri_path_format.isChecked():
return path.as_uri()
return path.as_posix()
def get_timecode_start_frame(self, framerate, file_metadata):
# use otio to convert timecode into frame number
timecode_start_frame = OTIO.opentime.from_timecode(
file_metadata["timecode"], framerate)
return timecode_start_frame.to_frames()
def export_otio(self, clips_data, output_path):
# first find the highest framerate and set it as default framerate
# for the timeline
timeline_framerate = 0
for clip_data in clips_data.values():
framerate = clip_data["framerate"]
if framerate > timeline_framerate:
timeline_framerate = framerate
# reduce decimal places to 3 - otio does not like more
timeline_framerate = float(f"{timeline_framerate:.4f}")
# create clips from the representations
clips = [
self.create_clip(name, clip_data, timeline_framerate)
for name, clip_data in clips_data.items()
]
timeline = OTIO.schema.timeline_from_clips(clips)
# set the timeline framerate to the highest framerate
timeline.global_start_time = OTIO.opentime.RationalTime(
0, timeline_framerate)
OTIO.adapters.write_to_file(timeline, output_path)
def get_image_info_metadata(
path_to_file,
keys=None,
logger=None,
):
"""Get flattened metadata from image file
With combined approach via FFMPEG and OIIOTool.
At first it will try to detect if the image input is supported by
OpenImageIO. If it is then it gets the metadata from the image using
OpenImageIO. If it is not supported by OpenImageIO then it will try to
get the metadata using FFprobe.
Args:
path_to_file (str): Path to image file.
keys (list[str]): List of keys that should be returned. If None then
all keys are returned. Keys are expected all lowercase.
Additional keys are:
- "framerate" - will be created from "r_frame_rate" or
"framespersecond" and evaluated to float value.
logger (logging.Logger): Logger used for logging.
"""
if logger is None:
logger = logging.getLogger(__name__)
def _ffprobe_metadata_conversion(metadata):
"""Convert ffprobe metadata unified format."""
output = {}
for key, val in metadata.items():
if key in ("tags", "disposition"):
output.update(val)
else:
output[key] = val
return output
def _get_video_metadata_from_ffprobe(ffprobe_stream):
"""Extract video metadata from ffprobe stream.
Args:
ffprobe_stream (dict): Stream data obtained from ffprobe.
Returns:
dict: Video metadata extracted from the ffprobe stream.
"""
video_stream = None
for stream in ffprobe_stream["streams"]:
if stream["codec_type"] == "video":
video_stream = stream
break
metadata_stream = _ffprobe_metadata_conversion(video_stream)
return metadata_stream
metadata_stream = None
ext = os.path.splitext(path_to_file)[-1].lower()
if ext not in IMAGE_EXTENSIONS:
logger.info(
(
'File extension "{}" is not supported by OpenImageIO.'
" Trying to get metadata using FFprobe."
).format(ext)
)
ffprobe_stream = get_ffprobe_data(path_to_file, logger)
if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0:
metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream)
if not metadata_stream and is_oiio_supported():
oiio_stream = get_oiio_info_for_input(path_to_file, logger=logger)
if "attribs" in (oiio_stream or {}):
metadata_stream = {}
for key, val in oiio_stream["attribs"].items():
if "smpte:" in key.lower():
key = key.replace("smpte:", "")
metadata_stream[key.lower()] = val
for key, val in oiio_stream.items():
if key == "attribs":
continue
metadata_stream[key] = val
else:
logger.info(
(
"OpenImageIO is not supported on this system."
" Trying to get metadata using FFprobe."
)
)
ffprobe_stream = get_ffprobe_data(path_to_file, logger)
if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0:
metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream)
if not metadata_stream:
logger.warning("Failed to get metadata from image file.")
return {}
if keys is None:
return metadata_stream
# create framerate key from available ffmpeg:r_frame_rate
# or oiiotool:framespersecond and evaluate its string expression
# value into flaot value
if (
"r_frame_rate" in metadata_stream
or "framespersecond" in metadata_stream
):
rate_info = metadata_stream.get("r_frame_rate")
if rate_info is None:
rate_info = metadata_stream.get("framespersecond")
# calculate framerate from string expression
if "/" in str(rate_info):
time, frame = str(rate_info).split("/")
rate_info = float(time) / float(frame)
try:
metadata_stream["framerate"] = float(str(rate_info))
except Exception as e:
logger.warning(
"Failed to evaluate '{}' value to framerate. Error: {}".format(
rate_info, e
)
)
# aggregate all required metadata from prepared metadata stream
output = {}
for key in keys:
for k, v in metadata_stream.items():
if key == k:
output[key] = v
break
if isinstance(v, dict) and key in v:
output[key] = v[key]
break
return output

View file

@ -53,8 +53,9 @@ class CollectContextEntities(pyblish.api.ContextPlugin):
context.data["folderEntity"] = folder_entity
context.data["taskEntity"] = task_entity
folder_attributes = folder_entity["attrib"]
context_attributes = (
task_entity["attrib"] if task_entity else folder_entity["attrib"]
)
# Task type
task_type = None
@ -63,12 +64,12 @@ class CollectContextEntities(pyblish.api.ContextPlugin):
context.data["taskType"] = task_type
frame_start = folder_attributes.get("frameStart")
frame_start = context_attributes.get("frameStart")
if frame_start is None:
frame_start = 1
self.log.warning("Missing frame start. Defaulting to 1.")
frame_end = folder_attributes.get("frameEnd")
frame_end = context_attributes.get("frameEnd")
if frame_end is None:
frame_end = 2
self.log.warning("Missing frame end. Defaulting to 2.")
@ -76,8 +77,8 @@ class CollectContextEntities(pyblish.api.ContextPlugin):
context.data["frameStart"] = frame_start
context.data["frameEnd"] = frame_end
handle_start = folder_attributes.get("handleStart") or 0
handle_end = folder_attributes.get("handleEnd") or 0
handle_start = context_attributes.get("handleStart") or 0
handle_end = context_attributes.get("handleEnd") or 0
context.data["handleStart"] = int(handle_start)
context.data["handleEnd"] = int(handle_end)
@ -87,7 +88,7 @@ class CollectContextEntities(pyblish.api.ContextPlugin):
context.data["frameStartHandle"] = frame_start_h
context.data["frameEndHandle"] = frame_end_h
context.data["fps"] = folder_attributes["fps"]
context.data["fps"] = context_attributes["fps"]
def _get_folder_entity(self, project_name, folder_path):
if not folder_path:

View file

@ -199,7 +199,7 @@ class ExtractBurnin(publish.Extractor):
if not burnins_per_repres:
self.log.debug(
"Skipped instance. No representations found matching a burnin"
"definition in: %s", burnin_defs
" definition in: %s", burnin_defs
)
return
@ -399,7 +399,7 @@ class ExtractBurnin(publish.Extractor):
add_repre_files_for_cleanup(instance, new_repre)
# Cleanup temp staging dir after procesisng of output definitions
# Cleanup temp staging dir after processing of output definitions
if do_convert:
temp_dir = repre["stagingDir"]
shutil.rmtree(temp_dir)
@ -420,6 +420,12 @@ class ExtractBurnin(publish.Extractor):
self.log.debug("Removed: \"{}\"".format(filepath))
def _get_burnin_options(self):
"""Get the burnin options from `ExtractBurnin` settings.
Returns:
dict[str, Any]: Burnin options.
"""
# Prepare burnin options
burnin_options = copy.deepcopy(self.default_options)
if self.options:
@ -696,7 +702,7 @@ class ExtractBurnin(publish.Extractor):
"""Prepare data for representation.
Args:
instance (Instance): Currently processed Instance.
instance (pyblish.api.Instance): Currently processed Instance.
repre (dict): Currently processed representation.
burnin_data (dict): Copy of basic burnin data based on instance
data.
@ -752,9 +758,11 @@ class ExtractBurnin(publish.Extractor):
Args:
profile (dict): Profile from presets matching current context.
instance (pyblish.api.Instance): Publish instance.
Returns:
list: Contain all valid output definitions.
list[dict[str, Any]]: Contain all valid output definitions.
"""
filtered_burnin_defs = []
@ -773,12 +781,11 @@ class ExtractBurnin(publish.Extractor):
if not self.families_filter_validation(
families, families_filters
):
self.log.debug((
"Skipped burnin definition \"{}\". Family"
" filters ({}) does not match current instance families: {}"
).format(
filename_suffix, str(families_filters), str(families)
))
self.log.debug(
f"Skipped burnin definition \"{filename_suffix}\"."
f" Family filters ({families_filters}) does not match"
f" current instance families: {families}"
)
continue
# Burnin values

View file

@ -1118,39 +1118,39 @@ ValidationArtistMessage QLabel {
font-weight: bold;
}
#ValidationActionButton {
#PublishActionButton {
border-radius: 0.2em;
padding: 4px 6px 4px 6px;
background: {color:bg-buttons};
}
#ValidationActionButton:hover {
#PublishActionButton:hover {
background: {color:bg-buttons-hover};
color: {color:font-hover};
}
#ValidationActionButton:disabled {
#PublishActionButton:disabled {
background: {color:bg-buttons-disabled};
}
#ValidationErrorTitleFrame {
#PublishErrorTitleFrame {
border-radius: 0.2em;
background: {color:bg-buttons};
}
#ValidationErrorTitleFrame:hover {
#PublishErrorTitleFrame:hover {
background: {color:bg-buttons-hover};
}
#ValidationErrorTitleFrame[selected="1"] {
#PublishErrorTitleFrame[selected="1"] {
background: {color:bg-view-selection};
}
#ValidationErrorInstanceList {
#PublishErrorInstanceList {
border-radius: 0;
}
#ValidationErrorInstanceList::item {
#PublishErrorInstanceList::item {
border-bottom: 1px solid {color:border};
border-left: 1px solid {color:border};
}

View file

@ -0,0 +1,16 @@
import pytest
from pathlib import Path
collect_ignore = ["vendor", "resources"]
RESOURCES_PATH = 'resources'
@pytest.fixture
def resources_path_factory():
def factory(*args):
dirpath = Path(__file__).parent / RESOURCES_PATH
for arg in args:
dirpath = dirpath / arg
return dirpath
return factory

View file

@ -0,0 +1,52 @@
import pytest
import logging
from pathlib import Path
from ayon_core.plugins.load.export_otio import get_image_info_metadata
logger = logging.getLogger('test_transcoding')
@pytest.mark.parametrize(
"resources_path_factory, metadata, expected, test_id",
[
(
Path(__file__).parent.parent
/ "resources"
/ "lib"
/ "transcoding"
/ "a01vfxd_sh010_plateP01_v002.1013.exr",
["timecode", "framerate"],
{"timecode": "01:00:06:03", "framerate": 23.976023976023978},
"test_01",
),
(
Path(__file__).parent.parent
/ "resources"
/ "lib"
/ "transcoding"
/ "a01vfxd_sh010_plateP01_v002.1013.exr",
["timecode", "width", "height", "duration"],
{"timecode": "01:00:06:03", "width": 1920, "height": 1080},
"test_02",
),
(
Path(__file__).parent.parent
/ "resources"
/ "lib"
/ "transcoding"
/ "a01vfxd_sh010_plateP01_v002.mov",
["width", "height", "duration"],
{"width": 1920, "height": 1080, "duration": "0.041708"},
"test_03",
),
],
)
def test_get_image_info_metadata_happy_path(
resources_path_factory, metadata, expected, test_id
):
path_to_file = resources_path_factory.as_posix()
returned_data = get_image_info_metadata(path_to_file, metadata, logger)
logger.info(f"Returned data: {returned_data}")
assert returned_data == expected

View file

@ -26,7 +26,7 @@ from ayon_core.tools.common_models import (
)
if TYPE_CHECKING:
from .models import CreatorItem
from .models import CreatorItem, PublishErrorInfo
class CardMessageTypes:
@ -543,14 +543,13 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
pass
@abstractmethod
def get_publish_error_msg(self) -> Union[str, None]:
def get_publish_error_info(self) -> Optional["PublishErrorInfo"]:
"""Current error message which cause fail of publishing.
Returns:
Union[str, None]: Message which will be showed to artist or
None.
"""
Optional[PublishErrorInfo]: Error info or None.
"""
pass
@abstractmethod
@ -558,7 +557,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
pass
@abstractmethod
def get_validation_errors(self):
def get_publish_errors_report(self):
pass
@abstractmethod

View file

@ -496,14 +496,14 @@ class PublisherController(
def get_publish_progress(self):
return self._publish_model.get_progress()
def get_publish_error_msg(self):
return self._publish_model.get_error_msg()
def get_publish_error_info(self):
return self._publish_model.get_error_info()
def get_publish_report(self):
return self._publish_model.get_publish_report()
def get_validation_errors(self):
return self._publish_model.get_validation_errors()
def get_publish_errors_report(self):
return self._publish_model.get_publish_errors_report()
def set_comment(self, comment):
"""Set comment from ui to pyblish context.

View file

@ -1,5 +1,5 @@
from .create import CreateModel, CreatorItem
from .publish import PublishModel
from .publish import PublishModel, PublishErrorInfo
__all__ = (
@ -7,4 +7,5 @@ __all__ = (
"CreatorItem",
"PublishModel",
"PublishErrorInfo",
)

View file

@ -15,7 +15,10 @@ from ayon_core.pipeline import (
OptionalPyblishPluginMixin,
)
from ayon_core.pipeline.plugin_discover import DiscoverResult
from ayon_core.pipeline.publish import get_publish_instance_label
from ayon_core.pipeline.publish import (
get_publish_instance_label,
PublishError,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherBackend
PUBLISH_EVENT_SOURCE = "publisher.publish.model"
@ -23,6 +26,53 @@ PUBLISH_EVENT_SOURCE = "publisher.publish.model"
PLUGIN_ORDER_OFFSET = 0.5
class PublishErrorInfo:
def __init__(
self,
message: str,
is_unknown_error: bool,
description: Optional[str] = None,
title: Optional[str] = None,
detail: Optional[str] = None,
):
self.message: str = message
self.is_unknown_error = is_unknown_error
self.description: str = description or message
self.title: Optional[str] = title or "Unknown error"
self.detail: Optional[str] = detail
def __eq__(self, other: Any) -> bool:
if not isinstance(other, PublishErrorInfo):
return False
return (
self.description == other.description
and self.is_unknown_error == other.is_unknown_error
and self.title == other.title
and self.detail == other.detail
)
def __ne__(self, other: Any) -> bool:
return not self.__eq__(other)
@classmethod
def from_exception(cls, exc) -> "PublishErrorInfo":
if isinstance(exc, PublishError):
return cls(
exc.message,
False,
exc.description,
title=exc.title,
detail=exc.detail,
)
if isinstance(exc, KnownPublishError):
msg = str(exc)
else:
msg = (
"Something went wrong. Send report"
" to your supervisor or Ynput team."
)
return cls(msg, True)
class PublishReportMaker:
"""Report for single publishing process.
@ -479,10 +529,10 @@ class PublishPluginsProxy:
)
class ValidationErrorItem:
"""Data driven validation error item.
class PublishErrorItem:
"""Data driven publish error item.
Prepared data container with information about validation error and it's
Prepared data container with information about publish error and it's
source plugin.
Can be converted to raw data and recreated should be used for controller
@ -490,11 +540,11 @@ class ValidationErrorItem:
Args:
instance_id (Optional[str]): Pyblish instance id to which is
validation error connected.
publish error connected.
instance_label (Optional[str]): Prepared instance label.
plugin_id (str): Pyblish plugin id which triggered the validation
plugin_id (str): Pyblish plugin id which triggered the publish
error. Id is generated using 'PublishPluginsProxy'.
context_validation (bool): Error happened on context.
is_context_plugin (bool): Error happened on context.
title (str): Error title.
description (str): Error description.
detail (str): Error detail.
@ -505,7 +555,8 @@ class ValidationErrorItem:
instance_id: Optional[str],
instance_label: Optional[str],
plugin_id: str,
context_validation: bool,
is_context_plugin: bool,
is_validation_error: bool,
title: str,
description: str,
detail: str
@ -513,7 +564,8 @@ class ValidationErrorItem:
self.instance_id: Optional[str] = instance_id
self.instance_label: Optional[str] = instance_label
self.plugin_id: str = plugin_id
self.context_validation: bool = context_validation
self.is_context_plugin: bool = is_context_plugin
self.is_validation_error: bool = is_validation_error
self.title: str = title
self.description: str = description
self.detail: str = detail
@ -529,7 +581,8 @@ class ValidationErrorItem:
"instance_id": self.instance_id,
"instance_label": self.instance_label,
"plugin_id": self.plugin_id,
"context_validation": self.context_validation,
"is_context_plugin": self.is_context_plugin,
"is_validation_error": self.is_validation_error,
"title": self.title,
"description": self.description,
"detail": self.detail,
@ -539,13 +592,13 @@ class ValidationErrorItem:
def from_result(
cls,
plugin_id: str,
error: PublishValidationError,
error: PublishError,
instance: Union[pyblish.api.Instance, None]
):
"""Create new object based on resukt from controller.
Returns:
ValidationErrorItem: New object with filled data.
PublishErrorItem: New object with filled data.
"""
instance_label = None
@ -561,6 +614,7 @@ class ValidationErrorItem:
instance_label,
plugin_id,
instance is None,
isinstance(error, PublishValidationError),
error.title,
error.description,
error.detail,
@ -571,11 +625,11 @@ class ValidationErrorItem:
return cls(**data)
class PublishValidationErrorsReport:
"""Publish validation errors report that can be parsed to raw data.
class PublishErrorsReport:
"""Publish errors report that can be parsed to raw data.
Args:
error_items (List[ValidationErrorItem]): List of validation errors.
error_items (List[PublishErrorItem]): List of publish errors.
plugin_action_items (Dict[str, List[PublishPluginActionItem]]): Action
items by plugin id.
@ -584,7 +638,7 @@ class PublishValidationErrorsReport:
self._error_items = error_items
self._plugin_action_items = plugin_action_items
def __iter__(self) -> Iterable[ValidationErrorItem]:
def __iter__(self) -> Iterable[PublishErrorItem]:
for item in self._error_items:
yield item
@ -658,7 +712,7 @@ class PublishValidationErrorsReport:
@classmethod
def from_data(
cls, data: Dict[str, Any]
) -> "PublishValidationErrorsReport":
) -> "PublishErrorsReport":
"""Recreate object from data.
Args:
@ -666,11 +720,11 @@ class PublishValidationErrorsReport:
using 'to_data' method.
Returns:
PublishValidationErrorsReport: New object based on data.
PublishErrorsReport: New object based on data.
"""
error_items = [
ValidationErrorItem.from_data(error_item)
PublishErrorItem.from_data(error_item)
for error_item in data["error_items"]
]
plugin_action_items = {}
@ -682,12 +736,12 @@ class PublishValidationErrorsReport:
return cls(error_items, plugin_action_items)
class PublishValidationErrors:
"""Object to keep track about validation errors by plugin."""
class PublishErrors:
"""Object to keep track about publish errors by plugin."""
def __init__(self):
self._plugins_proxy: Union[PublishPluginsProxy, None] = None
self._error_items: List[ValidationErrorItem] = []
self._error_items: List[PublishErrorItem] = []
self._plugin_action_items: Dict[
str, List[PublishPluginActionItem]
] = {}
@ -713,29 +767,29 @@ class PublishValidationErrors:
self._error_items = []
self._plugin_action_items = {}
def create_report(self) -> PublishValidationErrorsReport:
def create_report(self) -> PublishErrorsReport:
"""Create report based on currently existing errors.
Returns:
PublishValidationErrorsReport: Validation error report with all
PublishErrorsReport: Publish error report with all
error information and publish plugin action items.
"""
return PublishValidationErrorsReport(
return PublishErrorsReport(
self._error_items, self._plugin_action_items
)
def add_error(
self,
plugin: pyblish.api.Plugin,
error: PublishValidationError,
error: PublishError,
instance: Union[pyblish.api.Instance, None]
):
"""Add error from pyblish result.
Args:
plugin (pyblish.api.Plugin): Plugin which triggered error.
error (PublishValidationError): Validation error.
error (PublishError): Publish error.
instance (Union[pyblish.api.Instance, None]): Instance on which was
error raised or None if was raised on context.
"""
@ -750,7 +804,7 @@ class PublishValidationErrors:
error.title = plugin_label
self._error_items.append(
ValidationErrorItem.from_result(plugin_id, error, instance)
PublishErrorItem.from_result(plugin_id, error, instance)
)
if plugin_id in self._plugin_action_items:
return
@ -801,7 +855,7 @@ class PublishModel:
self._publish_comment_is_set: bool = False
# Any other exception that happened during publishing
self._publish_error_msg: Optional[str] = None
self._publish_error_info: Optional[PublishErrorInfo] = None
# Publishing is in progress
self._publish_is_running: bool = False
# Publishing is over validation order
@ -824,10 +878,8 @@ class PublishModel:
self._publish_context = None
# Pyblish report
self._publish_report: PublishReportMaker = PublishReportMaker()
# Store exceptions of validation error
self._publish_validation_errors: PublishValidationErrors = (
PublishValidationErrors()
)
# Store exceptions of publish error
self._publish_errors: PublishErrors = PublishErrors()
# This information is not much important for controller but for widget
# which can change (and set) the comment.
@ -851,7 +903,7 @@ class PublishModel:
self._publish_comment_is_set = False
self._publish_has_started = False
self._set_publish_error_msg(None)
self._set_publish_error_info(None)
self._set_progress(0)
self._set_is_running(False)
self._set_has_validated(False)
@ -881,7 +933,7 @@ class PublishModel:
)
for plugin in create_context.publish_plugins_mismatch_targets:
self._publish_report.set_plugin_skipped(plugin.id)
self._publish_validation_errors.reset(self._publish_plugins_proxy)
self._publish_errors.reset(self._publish_plugins_proxy)
self._set_max_progress(len(publish_plugins))
@ -974,11 +1026,11 @@ class PublishModel:
self._publish_context
)
def get_validation_errors(self) -> PublishValidationErrorsReport:
return self._publish_validation_errors.create_report()
def get_publish_errors_report(self) -> PublishErrorsReport:
return self._publish_errors.create_report()
def get_error_msg(self) -> Optional[str]:
return self._publish_error_msg
def get_error_info(self) -> Optional[PublishErrorInfo]:
return self._publish_error_info
def set_comment(self, comment: str):
# Ignore change of comment when publishing started
@ -1077,9 +1129,9 @@ class PublishModel:
{"value": value}
)
def _set_publish_error_msg(self, value: Optional[str]):
if self._publish_error_msg != value:
self._publish_error_msg = value
def _set_publish_error_info(self, value: Optional[PublishErrorInfo]):
if self._publish_error_info != value:
self._publish_error_info = value
self._emit_event(
"publish.publish_error.changed",
{"value": value}
@ -1225,32 +1277,33 @@ class PublishModel:
exception = result.get("error")
if exception:
has_validation_error = False
if (
isinstance(exception, PublishValidationError)
and not self._publish_has_validated
):
has_validation_error = True
result["is_validation_error"] = True
self._add_validation_error(result)
else:
if isinstance(exception, KnownPublishError):
msg = str(exception)
else:
msg = (
"Something went wrong. Send report"
" to your supervisor or Ynput team."
)
self._set_publish_error_msg(msg)
if isinstance(exception, PublishError):
if not exception.title:
exception.title = plugin.label or plugin.__name__
self._add_publish_error_to_report(result)
error_info = PublishErrorInfo.from_exception(exception)
self._set_publish_error_info(error_info)
self._set_is_crashed(True)
result["is_validation_error"] = has_validation_error
result["is_validation_error"] = False
self._publish_report.add_result(plugin.id, result)
def _add_validation_error(self, result: Dict[str, Any]):
self._set_has_validation_errors(True)
self._publish_validation_errors.add_error(
self._add_publish_error_to_report(result)
def _add_publish_error_to_report(self, result: Dict[str, Any]):
self._publish_errors.add_error(
result["plugin"],
result["error"],
result["instance"]

View file

@ -411,10 +411,13 @@ class PublishFrame(QtWidgets.QWidget):
"""Show error message to artist on publish crash."""
self._set_main_label("Error happened")
error_info = self._controller.get_publish_error_info()
self._message_label_top.setText(
self._controller.get_publish_error_msg()
)
error_message = "Unknown error happened"
if error_info is not None:
error_message = error_info.message
self._message_label_top.setText(error_message)
self._set_success_property(1)

View file

@ -26,7 +26,7 @@ from ayon_core.tools.publisher.constants import (
CONTEXT_LABEL,
)
from .widgets import IconValuePixmapLabel
from .widgets import PublishPixmapLabel, IconValuePixmapLabel
from .icons import (
get_pixmap,
get_image,
@ -42,7 +42,7 @@ INFO_VISIBLE = 1 << 6
class VerticalScrollArea(QtWidgets.QScrollArea):
"""Scroll area for validation error titles.
"""Scroll area for publish error titles.
The biggest difference is that the scroll area has scroll bar on left side
and resize of content will also resize scrollarea itself.
@ -126,7 +126,7 @@ class ActionButton(BaseClickableFrame):
def __init__(self, plugin_action_item, parent):
super().__init__(parent)
self.setObjectName("ValidationActionButton")
self.setObjectName("PublishActionButton")
self.plugin_action_item = plugin_action_item
@ -155,10 +155,10 @@ class ActionButton(BaseClickableFrame):
)
class ValidateActionsWidget(QtWidgets.QFrame):
class PublishActionsWidget(QtWidgets.QFrame):
"""Wrapper widget for plugin actions.
Change actions based on selected validation error.
Change actions based on selected publish error.
"""
def __init__(
@ -243,16 +243,16 @@ class ValidateActionsWidget(QtWidgets.QFrame):
self._controller.run_action(plugin_id, action_id)
# --- Validation error titles ---
class ValidationErrorInstanceList(QtWidgets.QListView):
"""List of publish instances that caused a validation error.
# --- Publish error titles ---
class PublishErrorInstanceList(QtWidgets.QListView):
"""List of publish instances that caused a publish error.
Instances are collected per plugin's validation error title.
Instances are collected per plugin's publish error title.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setObjectName("ValidationErrorInstanceList")
self.setObjectName("PublishErrorInstanceList")
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
@ -270,18 +270,19 @@ class ValidationErrorInstanceList(QtWidgets.QListView):
return result
class ValidationErrorTitleWidget(QtWidgets.QWidget):
"""Title of validation error.
class PublishErrorTitleWidget(QtWidgets.QWidget):
"""Title of publish error.
Widget is used as radio button so requires clickable functionality and
changing style on selection/deselection.
Has toggle button to show/hide instances on which validation error happened
Has toggle button to show/hide instances on which publish error happened
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(str)
instance_changed = QtCore.Signal(str)
_error_pixmap = None
def __init__(self, title_id, error_info, parent):
super().__init__(parent)
@ -290,30 +291,17 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self._error_info = error_info
self._selected = False
title_frame = ClickableFrame(self)
title_frame.setObjectName("ValidationErrorTitleFrame")
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
toggle_instance_btn.setObjectName("ArrowBtn")
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.addWidget(label_widget, 1)
title_frame_layout.addWidget(toggle_instance_btn, 0)
instances_model = QtGui.QStandardItemModel()
instance_ids = []
items = []
context_validation = False
is_context_plugin = False
is_crashing_error = False
for error_item in error_info["error_items"]:
context_validation = error_item.context_validation
if context_validation:
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
is_crashing_error = not error_item.is_validation_error
is_context_plugin = error_item.is_context_plugin
if is_context_plugin:
instance_ids.append(CONTEXT_ID)
# Add fake item to have minimum size hint of view widget
items.append(QtGui.QStandardItem(CONTEXT_LABEL))
@ -333,7 +321,33 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
root_item = instances_model.invisibleRootItem()
root_item.appendRows(items)
instances_view = ValidationErrorInstanceList(self)
title_frame = ClickableFrame(self)
title_frame.setObjectName("PublishErrorTitleFrame")
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
toggle_instance_btn.setObjectName("ArrowBtn")
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
if is_context_plugin:
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
icon_label = None
if is_crashing_error:
error_pixmap = self._get_error_pixmap()
icon_label = PublishPixmapLabel(error_pixmap, self)
label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.setContentsMargins(8, 8, 8, 8)
title_frame_layout.setSpacing(0)
if icon_label is not None:
title_frame_layout.addWidget(icon_label, 0)
title_frame_layout.addSpacing(6)
title_frame_layout.addWidget(label_widget, 1)
title_frame_layout.addWidget(toggle_instance_btn, 0)
instances_view = PublishErrorInstanceList(self)
instances_view.setModel(instances_model)
self.setLayoutDirection(QtCore.Qt.LeftToRight)
@ -352,7 +366,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
layout.addWidget(view_widget, 0)
view_widget.setVisible(False)
if not context_validation:
if not is_context_plugin:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
title_frame.clicked.connect(self._mouse_release_callback)
@ -369,7 +383,8 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self._instances_model = instances_model
self._instances_view = instances_view
self._context_validation = context_validation
self._is_context_plugin = is_context_plugin
self._is_crashing_error = is_crashing_error
self._instance_ids = instance_ids
self._expanded = False
@ -411,6 +426,10 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
def id(self):
return self._title_id
@property
def is_crashing_error(self):
return self._is_crashing_error
def _change_style_property(self, selected):
"""Change style of widget based on selection."""
@ -438,6 +457,12 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self.selected.emit(self._title_id)
self._set_expanded(True)
@classmethod
def _get_error_pixmap(cls):
if cls._error_pixmap is None:
cls._error_pixmap = get_pixmap("error")
return cls._error_pixmap
def _on_toggle_btn_click(self):
"""Show/hide instances list."""
@ -450,7 +475,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
elif expanded is self._expanded:
return
if expanded and self._context_validation:
if expanded and self._is_context_plugin:
return
self._expanded = expanded
@ -464,7 +489,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self.instance_changed.emit(self._title_id)
def get_selected_instances(self):
if self._context_validation:
if self._is_context_plugin:
return [CONTEXT_ID]
sel_model = self._instances_view.selectionModel()
return [
@ -477,21 +502,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
return list(self._instance_ids)
class ValidationArtistMessage(QtWidgets.QWidget):
def __init__(self, message, parent):
super().__init__(parent)
artist_msg_label = QtWidgets.QLabel(message, self)
artist_msg_label.setAlignment(QtCore.Qt.AlignCenter)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(
artist_msg_label, 1, QtCore.Qt.AlignCenter
)
class ValidationErrorsView(QtWidgets.QWidget):
class PublishErrorsView(QtWidgets.QWidget):
selection_changed = QtCore.Signal()
def __init__(self, parent):
@ -510,8 +521,9 @@ class ValidationErrorsView(QtWidgets.QWidget):
# scroll widget
errors_layout.setContentsMargins(5, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(errors_scroll, 1)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.addWidget(errors_scroll, 1)
self._errors_widget = errors_widget
self._errors_layout = errors_layout
@ -533,28 +545,30 @@ class ValidationErrorsView(QtWidgets.QWidget):
"""Set errors into context and created titles.
Args:
validation_error_report (PublishValidationErrorsReport): Report
with information about validation errors and publish plugin
grouped_error_items (List[Dict[str, Any]]): Report
with information about publish errors and publish plugin
actions.
"""
self._clear()
first_id = None
select_id = None
for title_item in grouped_error_items:
title_id = title_item["id"]
if first_id is None:
first_id = title_id
widget = ValidationErrorTitleWidget(title_id, title_item, self)
if select_id is None:
select_id = title_id
widget = PublishErrorTitleWidget(title_id, title_item, self)
widget.selected.connect(self._on_select)
widget.instance_changed.connect(self._on_instance_change)
if widget.is_crashing_error:
select_id = title_id
self._errors_layout.addWidget(widget)
self._title_widgets[title_id] = widget
self._errors_layout.addStretch(1)
if first_id:
self._title_widgets[first_id].set_selected(True)
if select_id:
self._title_widgets[select_id].set_selected(True)
else:
self.selection_changed.emit()
@ -1319,6 +1333,7 @@ class InstancesLogsView(QtWidgets.QFrame):
content_widget = QtWidgets.QWidget(content_wrap_widget)
content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setSpacing(15)
scroll_area.setWidget(content_wrap_widget)
@ -1454,6 +1469,78 @@ class InstancesLogsView(QtWidgets.QFrame):
self._update_instances()
class ErrorDetailWidget(QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
error_detail_top = ClickableFrame(self)
line_l_widget = SeparatorWidget(1, parent=error_detail_top)
error_detail_expand_btn = ClassicExpandBtn(error_detail_top)
error_detail_expand_label = QtWidgets.QLabel(
"Details", error_detail_top)
line_r_widget = SeparatorWidget(1, parent=error_detail_top)
error_detail_top_l = QtWidgets.QHBoxLayout(error_detail_top)
error_detail_top_l.setContentsMargins(0, 0, 10, 0)
error_detail_top_l.addWidget(line_l_widget, 1)
error_detail_top_l.addWidget(error_detail_expand_btn, 0)
error_detail_top_l.addWidget(error_detail_expand_label, 0)
error_detail_top_l.addWidget(line_r_widget, 9)
error_detail_input = ExpandingTextEdit(self)
error_detail_input.setObjectName("InfoText")
error_detail_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(error_detail_top, 0)
main_layout.addWidget(error_detail_input, 0)
main_layout.addStretch(1)
error_detail_input.setVisible(not error_detail_expand_btn.collapsed)
error_detail_top.clicked.connect(self._on_detail_toggle)
self._error_detail_top = error_detail_top
self._error_detail_expand_btn = error_detail_expand_btn
self._error_detail_input = error_detail_input
def set_detail(self, detail):
if not detail:
self._set_visible_inputs(False)
return
if commonmark:
self._error_detail_input.setHtml(
commonmark.commonmark(detail)
)
elif hasattr(self._error_detail_input, "setMarkdown"):
self._error_detail_input.setMarkdown(detail)
else:
self._error_detail_input.setText(detail)
self._set_visible_inputs(True)
def _set_visible_inputs(self, visible):
self._error_detail_top.setVisible(visible)
input_visible = visible
if input_visible:
input_visible = not self._error_detail_expand_btn.collapsed
self._error_detail_input.setVisible(input_visible)
def _on_detail_toggle(self):
self._error_detail_expand_btn.set_collapsed()
self._error_detail_input.setVisible(
not self._error_detail_expand_btn.collapsed
)
class CrashWidget(QtWidgets.QWidget):
"""Widget shown when publishing crashes.
@ -1488,6 +1575,8 @@ class CrashWidget(QtWidgets.QWidget):
"Save to disk", btns_widget)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.setSpacing(0)
btns_layout.addStretch(1)
btns_layout.addWidget(copy_clipboard_btn, 0)
btns_layout.addSpacing(20)
@ -1495,11 +1584,13 @@ class CrashWidget(QtWidgets.QWidget):
btns_layout.addStretch(1)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(0)
layout.addStretch(1)
layout.addWidget(main_label, 0)
layout.addSpacing(20)
layout.addSpacing(30)
layout.addWidget(report_label, 0)
layout.addSpacing(20)
layout.addSpacing(30)
layout.addWidget(btns_widget, 0)
layout.addStretch(2)
@ -1517,7 +1608,7 @@ class CrashWidget(QtWidgets.QWidget):
"export_report.request", {}, "report_page")
class ErrorDetailsWidget(QtWidgets.QWidget):
class PublishFailWidget(QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
@ -1530,34 +1621,7 @@ class ErrorDetailsWidget(QtWidgets.QWidget):
)
# Error 'Details' widget -> Collapsible
error_details_widget = QtWidgets.QWidget(inputs_widget)
error_details_top = ClickableFrame(error_details_widget)
error_details_expand_btn = ClassicExpandBtn(error_details_top)
error_details_expand_label = QtWidgets.QLabel(
"Details", error_details_top)
line_widget = SeparatorWidget(1, parent=error_details_top)
error_details_top_l = QtWidgets.QHBoxLayout(error_details_top)
error_details_top_l.setContentsMargins(0, 0, 10, 0)
error_details_top_l.addWidget(error_details_expand_btn, 0)
error_details_top_l.addWidget(error_details_expand_label, 0)
error_details_top_l.addWidget(line_widget, 1)
error_details_input = ExpandingTextEdit(error_details_widget)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
error_details_input.setVisible(not error_details_expand_btn.collapsed)
error_details_layout = QtWidgets.QVBoxLayout(error_details_widget)
error_details_layout.setContentsMargins(0, 0, 0, 0)
error_details_layout.addWidget(error_details_top, 0)
error_details_layout.addWidget(error_details_input, 0)
error_details_layout.addStretch(1)
error_details_widget = ErrorDetailWidget(inputs_widget)
# Description and Details layout
inputs_layout = QtWidgets.QVBoxLayout(inputs_widget)
@ -1570,17 +1634,8 @@ class ErrorDetailsWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(inputs_widget, 1)
error_details_top.clicked.connect(self._on_detail_toggle)
self._error_details_widget = error_details_widget
self._error_description_input = error_description_input
self._error_details_expand_btn = error_details_expand_btn
self._error_details_input = error_details_input
def _on_detail_toggle(self):
self._error_details_expand_btn.set_collapsed()
self._error_details_input.setVisible(
not self._error_details_expand_btn.collapsed)
self._error_details_widget = error_details_widget
def set_error_item(self, error_item):
detail = ""
@ -1589,23 +1644,18 @@ class ErrorDetailsWidget(QtWidgets.QWidget):
description = error_item.description or description
detail = error_item.detail or detail
self._error_details_widget.set_detail(detail)
if commonmark:
self._error_description_input.setHtml(
commonmark.commonmark(description)
)
self._error_details_input.setHtml(
commonmark.commonmark(detail)
)
elif hasattr(self._error_details_input, "setMarkdown"):
elif hasattr(self._error_description_input, "setMarkdown"):
self._error_description_input.setMarkdown(description)
self._error_details_input.setMarkdown(detail)
else:
self._error_description_input.setText(description)
self._error_details_input.setText(detail)
self._error_details_widget.setVisible(bool(detail))
class ReportsWidget(QtWidgets.QWidget):
@ -1622,7 +1672,7 @@ class ReportsWidget(QtWidgets.QWidget):
# Validation errors layout
# Publish errors layout
Views Actions
Details
@ -1641,12 +1691,12 @@ class ReportsWidget(QtWidgets.QWidget):
instances_view = PublishInstancesViewWidget(controller, views_widget)
validation_error_view = ValidationErrorsView(views_widget)
publish_error_view = PublishErrorsView(views_widget)
views_layout = QtWidgets.QStackedLayout(views_widget)
views_layout.setContentsMargins(0, 0, 0, 0)
views_layout.addWidget(instances_view)
views_layout.addWidget(validation_error_view)
views_layout.addWidget(publish_error_view)
views_layout.setCurrentWidget(instances_view)
@ -1655,10 +1705,13 @@ class ReportsWidget(QtWidgets.QWidget):
details_widget.setObjectName("PublishInstancesDetails")
# Actions widget
actions_widget = ValidateActionsWidget(controller, details_widget)
actions_widget = PublishActionsWidget(controller, details_widget)
pages_widget = QtWidgets.QWidget(details_widget)
# Crash information
crash_widget = CrashWidget(controller, details_widget)
# Logs view
logs_view = InstancesLogsView(pages_widget)
@ -1671,30 +1724,24 @@ class ReportsWidget(QtWidgets.QWidget):
detail_input_scroll = QtWidgets.QScrollArea(pages_widget)
detail_inputs_widget = ErrorDetailsWidget(detail_input_scroll)
detail_inputs_widget = PublishFailWidget(detail_input_scroll)
detail_inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
detail_input_scroll.setWidget(detail_inputs_widget)
detail_input_scroll.setWidgetResizable(True)
detail_input_scroll.setViewportMargins(0, 0, 0, 0)
# Crash information
crash_widget = CrashWidget(controller, details_widget)
# Layout pages
pages_layout = QtWidgets.QHBoxLayout(pages_widget)
pages_layout.setContentsMargins(0, 0, 0, 0)
pages_layout.addWidget(crash_widget, 1)
pages_layout.addWidget(logs_view, 1)
pages_layout.addWidget(detail_inputs_spacer, 0)
pages_layout.addWidget(detail_input_scroll, 1)
pages_layout.addWidget(crash_widget, 1)
details_layout = QtWidgets.QVBoxLayout(details_widget)
margins = details_layout.contentsMargins()
margins.setTop(margins.top() * 2)
margins.setBottom(margins.bottom() * 2)
details_layout.setContentsMargins(margins)
details_layout.setSpacing(margins.top())
details_layout.setContentsMargins(8, 16, 8, 16)
details_layout.setSpacing(8)
details_layout.addWidget(actions_widget, 0)
details_layout.addWidget(pages_widget, 1)
@ -1704,12 +1751,12 @@ class ReportsWidget(QtWidgets.QWidget):
content_layout.addWidget(details_widget, 1)
instances_view.selection_changed.connect(self._on_instance_selection)
validation_error_view.selection_changed.connect(
publish_error_view.selection_changed.connect(
self._on_error_selection)
self._views_layout = views_layout
self._instances_view = instances_view
self._validation_error_view = validation_error_view
self._publish_error_view = publish_error_view
self._actions_widget = actions_widget
self._detail_inputs_widget = detail_inputs_widget
@ -1720,7 +1767,7 @@ class ReportsWidget(QtWidgets.QWidget):
self._controller: AbstractPublisherFrontend = controller
self._validation_errors_by_id = {}
self._publish_errors_by_id = {}
def _get_instance_items(self):
report = self._controller.get_publish_report()
@ -1750,40 +1797,50 @@ class ReportsWidget(QtWidgets.QWidget):
return instance_items
def update_data(self):
view = self._instances_view
validation_error_mode = False
if (
not self._controller.publish_has_crashed()
and self._controller.publish_has_validation_errors()
):
view = self._validation_error_view
validation_error_mode = True
has_validation_error = self._controller.publish_has_validation_errors()
has_finished = self._controller.publish_has_finished()
has_crashed = self._controller.publish_has_crashed()
error_info = None
if has_crashed:
error_info = self._controller.get_publish_error_info()
publish_error_mode = False
if error_info is not None:
publish_error_mode = not error_info.is_unknown_error
elif has_validation_error:
publish_error_mode = True
if publish_error_mode:
view = self._publish_error_view
else:
view = self._instances_view
self._actions_widget.set_visible_mode(validation_error_mode)
self._detail_inputs_spacer.setVisible(validation_error_mode)
self._detail_input_scroll.setVisible(validation_error_mode)
self._views_layout.setCurrentWidget(view)
is_crashed = self._controller.publish_has_crashed()
self._crash_widget.setVisible(is_crashed)
self._logs_view.setVisible(not is_crashed)
self._actions_widget.set_visible_mode(publish_error_mode)
self._detail_inputs_spacer.setVisible(publish_error_mode)
self._detail_input_scroll.setVisible(publish_error_mode)
logs_visible = publish_error_mode or has_finished or not has_crashed
self._logs_view.setVisible(logs_visible)
self._crash_widget.setVisible(not logs_visible)
# Instance view & logs update
instance_items = self._get_instance_items()
self._instances_view.update_instances(instance_items)
self._logs_view.update_instances(instance_items)
# Validation errors
validation_errors = self._controller.get_validation_errors()
grouped_error_items = validation_errors.group_items_by_title()
# Publish errors
publish_errors_report = self._controller.get_publish_errors_report()
grouped_error_items = publish_errors_report.group_items_by_title()
validation_errors_by_id = {
publish_errors_by_id = {
title_item["id"]: title_item
for title_item in grouped_error_items
}
self._validation_errors_by_id = validation_errors_by_id
self._validation_error_view.set_errors(grouped_error_items)
self._publish_errors_by_id = publish_errors_by_id
self._publish_error_view.set_errors(grouped_error_items)
def _on_instance_selection(self):
instance_ids = self._instances_view.get_selected_instance_ids()
@ -1791,8 +1848,8 @@ class ReportsWidget(QtWidgets.QWidget):
def _on_error_selection(self):
title_id, instance_ids = (
self._validation_error_view.get_selected_items())
error_info = self._validation_errors_by_id.get(title_id)
self._publish_error_view.get_selected_items())
error_info = self._publish_errors_by_id.get(title_id)
if error_info is None:
self._actions_widget.set_error_info(None)
self._detail_inputs_widget.set_error_item(None)
@ -1820,12 +1877,12 @@ class ReportPageWidget(QtWidgets.QFrame):
2. Publishing is paused.
3. Publishing successfully finished. > Instances with logs.
4. Publishing crashed.
5. Crashed because of validation error. > Errors with logs.
5. Crashed because of publish error. > Errors with logs.
This widget is shown if validation errors happened during validation part.
This widget is shown if publish errors happened.
Shows validation error titles with instances on which they happened
and validation error detail with possible actions (repair).
Shows publish error titles with instances on which they happened
and publish error detail with possible actions (repair).
"""
def __init__(

View file

@ -540,11 +540,38 @@ class ClassicExpandBtnLabel(ExpandBtnLabel):
right_arrow_path = get_style_image_path("right_arrow")
down_arrow_path = get_style_image_path("down_arrow")
def _normalize_pixmap(self, pixmap):
if pixmap.width() == pixmap.height():
return pixmap
width = pixmap.width()
height = pixmap.height()
size = max(width, height)
pos_x = 0
pos_y = 0
if width > height:
pos_y = (size - height) // 2
else:
pos_x = (size - width) // 2
new_pix = QtGui.QPixmap(size, size)
new_pix.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(new_pix)
render_hints = (
QtGui.QPainter.Antialiasing
| QtGui.QPainter.SmoothPixmapTransform
)
if hasattr(QtGui.QPainter, "HighQualityAntialiasing"):
render_hints |= QtGui.QPainter.HighQualityAntialiasing
painter.setRenderHints(render_hints)
painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap)
painter.end()
return new_pix
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(self.right_arrow_path)
return self._normalize_pixmap(QtGui.QPixmap(self.right_arrow_path))
def _create_expanded_pixmap(self):
return QtGui.QPixmap(self.down_arrow_path)
return self._normalize_pixmap(QtGui.QPixmap(self.down_arrow_path))
class ClassicExpandBtn(ExpandBtn):

View file

@ -23,6 +23,7 @@ ayon-python-api = "^1.0"
ruff = "^0.3.3"
pre-commit = "^3.6.2"
codespell = "^2.2.6"
semver = "^3.0.2"
[tool.ruff]
@ -113,3 +114,12 @@ quiet-level = 3
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"
addopts = "-ra -q"
testpaths = [
"client/ayon_core/tests"
]