mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into bugfix/#895_fix_extract_otio_review
This commit is contained in:
commit
ee749d052e
22 changed files with 1166 additions and 319 deletions
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -788,6 +788,11 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
colorspace = product.colorspace
|
||||
break
|
||||
|
||||
if isinstance(files, (list, tuple)):
|
||||
files = [os.path.basename(f) for f in files]
|
||||
else:
|
||||
files = os.path.basename(files)
|
||||
|
||||
rep = {
|
||||
"name": ext,
|
||||
"ext": ext,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy
|
|||
from ayon_core.pipeline.delivery import (
|
||||
get_format_dict,
|
||||
check_destination_path,
|
||||
deliver_single_file,
|
||||
deliver_sequence,
|
||||
deliver_single_file
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -231,51 +230,39 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
self.log
|
||||
]
|
||||
|
||||
if repre.get("files"):
|
||||
src_paths = []
|
||||
for repre_file in repre["files"]:
|
||||
src_path = self.anatomy.fill_root(repre_file["path"])
|
||||
src_paths.append(src_path)
|
||||
sources_and_frames = collect_frames(src_paths)
|
||||
src_paths = []
|
||||
for repre_file in repre["files"]:
|
||||
src_path = self.anatomy.fill_root(repre_file["path"])
|
||||
src_paths.append(src_path)
|
||||
sources_and_frames = collect_frames(src_paths)
|
||||
|
||||
frames = set(sources_and_frames.values())
|
||||
frames.discard(None)
|
||||
first_frame = None
|
||||
if frames:
|
||||
first_frame = min(frames)
|
||||
frames = set(sources_and_frames.values())
|
||||
frames.discard(None)
|
||||
first_frame = None
|
||||
if frames:
|
||||
first_frame = min(frames)
|
||||
|
||||
for src_path, frame in sources_and_frames.items():
|
||||
args[0] = src_path
|
||||
# Renumber frames
|
||||
if renumber_frame and frame is not None:
|
||||
# Calculate offset between
|
||||
# first frame and current frame
|
||||
# - '0' for first frame
|
||||
offset = frame_offset - int(first_frame)
|
||||
# Add offset to new frame start
|
||||
dst_frame = int(frame) + offset
|
||||
if dst_frame < 0:
|
||||
msg = "Renumber frame has a smaller number than original frame" # noqa
|
||||
report_items[msg].append(src_path)
|
||||
self.log.warning("{} <{}>".format(
|
||||
msg, dst_frame))
|
||||
continue
|
||||
frame = dst_frame
|
||||
for src_path, frame in sources_and_frames.items():
|
||||
args[0] = src_path
|
||||
# Renumber frames
|
||||
if renumber_frame and frame is not None:
|
||||
# Calculate offset between
|
||||
# first frame and current frame
|
||||
# - '0' for first frame
|
||||
offset = frame_offset - int(first_frame)
|
||||
# Add offset to new frame start
|
||||
dst_frame = int(frame) + offset
|
||||
if dst_frame < 0:
|
||||
msg = "Renumber frame has a smaller number than original frame" # noqa
|
||||
report_items[msg].append(src_path)
|
||||
self.log.warning("{} <{}>".format(
|
||||
msg, dst_frame))
|
||||
continue
|
||||
frame = dst_frame
|
||||
|
||||
if frame is not None:
|
||||
anatomy_data["frame"] = frame
|
||||
new_report_items, uploaded = deliver_single_file(*args)
|
||||
report_items.update(new_report_items)
|
||||
self._update_progress(uploaded)
|
||||
else: # fallback for Pype2 and representations without files
|
||||
frame = repre["context"].get("frame")
|
||||
if frame:
|
||||
repre["context"]["frame"] = len(str(frame)) * "#"
|
||||
|
||||
if not frame:
|
||||
new_report_items, uploaded = deliver_single_file(*args)
|
||||
else:
|
||||
new_report_items, uploaded = deliver_sequence(*args)
|
||||
if frame is not None:
|
||||
anatomy_data["frame"] = frame
|
||||
new_report_items, uploaded = deliver_single_file(*args)
|
||||
report_items.update(new_report_items)
|
||||
self._update_progress(uploaded)
|
||||
|
||||
|
|
|
|||
591
client/ayon_core/plugins/load/export_otio.py
Normal file
591
client/ayon_core/plugins/load/export_otio.py
Normal 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
|
||||
|
|
@ -9,7 +9,14 @@ from ayon_api import (
|
|||
|
||||
|
||||
class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
|
||||
"""Connecting version level dependency links"""
|
||||
"""Connecting version level dependency links
|
||||
|
||||
Handles links:
|
||||
- generative - what gets produced from workfile
|
||||
- reference - what was loaded into workfile
|
||||
|
||||
It expects workfile instance is being published.
|
||||
"""
|
||||
|
||||
order = pyblish.api.IntegratorOrder + 0.2
|
||||
label = "Connect Dependency InputLinks AYON"
|
||||
|
|
@ -47,6 +54,11 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
|
|||
self.create_links_on_server(context, new_links_by_type)
|
||||
|
||||
def split_instances(self, context):
|
||||
"""Separates published instances into workfile and other
|
||||
|
||||
Returns:
|
||||
(tuple(pyblish.plugin.Instance), list(pyblish.plugin.Instance))
|
||||
"""
|
||||
workfile_instance = None
|
||||
other_instances = []
|
||||
|
||||
|
|
@ -83,6 +95,15 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
|
|||
def create_workfile_links(
|
||||
self, workfile_instance, other_instances, new_links_by_type
|
||||
):
|
||||
"""Adds links (generative and reference) for workfile.
|
||||
|
||||
Args:
|
||||
workfile_instance (pyblish.plugin.Instance): published workfile
|
||||
other_instances (list[pyblish.plugin.Instance]): other published
|
||||
instances
|
||||
new_links_by_type (dict[str, list[str]]): dictionary collecting new
|
||||
created links by its type
|
||||
"""
|
||||
if workfile_instance is None:
|
||||
self.log.warn("No workfile in this publish session.")
|
||||
return
|
||||
|
|
@ -97,7 +118,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
|
|||
instance.data["versionEntity"]["id"],
|
||||
)
|
||||
|
||||
loaded_versions = workfile_instance.context.get("loadedVersions")
|
||||
loaded_versions = workfile_instance.context.data.get("loadedVersions")
|
||||
if not loaded_versions:
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
|
|||
16
client/ayon_core/tests/conftest.py
Normal file
16
client/ayon_core/tests/conftest.py
Normal 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
|
||||
52
client/ayon_core/tests/plugins/load/test_export_otio.py
Normal file
52
client/ayon_core/tests/plugins/load/test_export_otio.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,6 +1,9 @@
|
|||
import collections
|
||||
|
||||
from ayon_api import get_representations, get_versions_links
|
||||
from ayon_api import (
|
||||
get_representations,
|
||||
get_versions_links,
|
||||
)
|
||||
|
||||
from ayon_core.lib import Logger, NestedCacheItem
|
||||
from ayon_core.addon import AddonsManager
|
||||
|
|
@ -509,18 +512,19 @@ class SiteSyncModel:
|
|||
"reference"
|
||||
)
|
||||
for link_repre_id in links:
|
||||
try:
|
||||
if not self._sitesync_addon.is_representation_on_site(
|
||||
project_name,
|
||||
link_repre_id,
|
||||
site_name
|
||||
):
|
||||
print("Adding {} to linked representation: {}".format(
|
||||
site_name, link_repre_id))
|
||||
self._sitesync_addon.add_site(
|
||||
project_name,
|
||||
link_repre_id,
|
||||
site_name,
|
||||
force=False
|
||||
force=True
|
||||
)
|
||||
except Exception:
|
||||
# do not add/reset working site for references
|
||||
log.debug("Site present", exc_info=True)
|
||||
|
||||
def _get_linked_representation_id(
|
||||
self,
|
||||
|
|
@ -575,7 +579,7 @@ class SiteSyncModel:
|
|||
project_name,
|
||||
versions_to_check,
|
||||
link_types=link_types,
|
||||
link_direction="out")
|
||||
link_direction="in") # looking for 'in'puts for version
|
||||
|
||||
versions_to_check = set()
|
||||
for links in versions_links.values():
|
||||
|
|
@ -584,9 +588,6 @@ class SiteSyncModel:
|
|||
if link["entityType"] != "version":
|
||||
continue
|
||||
entity_id = link["entityId"]
|
||||
# Skip already found linked version ids
|
||||
if entity_id in linked_version_ids:
|
||||
continue
|
||||
linked_version_ids.add(entity_id)
|
||||
versions_to_check.add(entity_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue