Merge branch 'develop' into feature/AY-5235_Delivery-Farm-Publishing

This commit is contained in:
Jakub Jezek 2025-01-24 10:31:32 +01:00
commit 400a3e8ff8
No known key found for this signature in database
GPG key ID: 06DBD609ADF27FD9
32 changed files with 523 additions and 204 deletions

View file

@ -26,10 +26,12 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"photoshop",
"tvpaint",
"substancepainter",
"substancedesigner",
"aftereffects",
"wrap",
"openrv",
"cinema4d"
"cinema4d",
"silhouette",
}
launch_types = {LaunchTypes.local}

View file

@ -10,6 +10,7 @@ class OCIOEnvHook(PreLaunchHook):
order = 0
hosts = {
"substancepainter",
"substancedesigner",
"fusion",
"blender",
"aftereffects",
@ -20,7 +21,8 @@ class OCIOEnvHook(PreLaunchHook):
"hiero",
"resolve",
"openrv",
"cinema4d"
"cinema4d",
"silhouette",
}
launch_types = set()

View file

@ -117,10 +117,7 @@ class HostDirmap(ABC):
It checks if Site Sync is enabled and user chose to use local
site, in that case configuration in Local Settings takes precedence
"""
dirmap_label = "{}-dirmap".format(self.host_name)
mapping_sett = self.project_settings[self.host_name].get(dirmap_label,
{})
mapping_sett = self.project_settings[self.host_name].get("dirmap", {})
local_mapping = self._get_local_sync_dirmap()
mapping_enabled = mapping_sett.get("enabled") or bool(local_mapping)
if not mapping_enabled:

View file

@ -561,9 +561,6 @@ class FormattingPart:
"""
key = self._template_base
if key in result.really_used_values:
result.add_output(result.really_used_values[key])
return result
# ensure key is properly formed [({})] properly closed.
if not self.validate_key_is_matched(key):

View file

@ -15,7 +15,7 @@ from ayon_core.pipeline.plugin_discover import (
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline import get_staging_dir_info
from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
@ -833,7 +833,7 @@ class Creator(BaseCreator):
"""
return self.pre_create_attr_defs
def get_staging_dir(self, instance):
def get_staging_dir(self, instance) -> Optional[StagingDir]:
"""Return the staging dir and persistence from instance.
Args:
@ -915,7 +915,8 @@ class Creator(BaseCreator):
instance.transient_data.update({
"stagingDir": staging_dir_path,
"stagingDir_persistent": staging_dir_info.persistent,
"stagingDir_persistent": staging_dir_info.is_persistent,
"stagingDir_is_custom": staging_dir_info.is_custom,
})
self.log.info(f"Applied staging dir to instance: {staging_dir_path}")

View file

@ -196,11 +196,11 @@ def is_clip_from_media_sequence(otio_clip):
return is_input_sequence or is_input_sequence_legacy
def remap_range_on_file_sequence(otio_clip, in_out_range):
def remap_range_on_file_sequence(otio_clip, otio_range):
"""
Args:
otio_clip (otio.schema.Clip): The OTIO clip to check.
in_out_range (tuple[float, float]): The in-out range to remap.
otio_range (otio.schema.TimeRange): The trim range to apply.
Returns:
tuple(int, int): The remapped range as discrete frame number.
@ -211,17 +211,24 @@ def remap_range_on_file_sequence(otio_clip, in_out_range):
if not is_clip_from_media_sequence(otio_clip):
raise ValueError(f"Cannot map on non-file sequence clip {otio_clip}.")
try:
media_in_trimmed, media_out_trimmed = in_out_range
except ValueError as error:
raise ValueError("Invalid in_out_range provided.") from error
media_ref = otio_clip.media_reference
available_range = otio_clip.available_range()
source_range = otio_clip.source_range
available_range_rate = available_range.start_time.rate
media_in = available_range.start_time.value
# Backward-compatibility for Hiero OTIO exporter.
# NTSC compatibility might introduce floating rates, when these are
# not exactly the same (23.976 vs 23.976024627685547)
# this will cause precision issue in computation.
# Currently round to 2 decimals for comparison,
# but this should always rescale after that.
rounded_av_rate = round(available_range_rate, 2)
rounded_range_rate = round(otio_range.start_time.rate, 2)
if rounded_av_rate != rounded_range_rate:
raise ValueError("Inconsistent range between clip and provided clip")
source_range = otio_clip.source_range
media_in = available_range.start_time
available_range_start_frame = (
available_range.start_time.to_frames()
)
@ -231,19 +238,26 @@ def remap_range_on_file_sequence(otio_clip, in_out_range):
# source range for image sequence. Following code maintain
# backward-compatibility by adjusting media_in
# while we are updating those.
conformed_src_in = source_range.start_time.rescaled_to(
available_range_rate
)
if (
is_clip_from_media_sequence(otio_clip)
and available_range_start_frame == media_ref.start_frame
and source_range.start_time.to_frames() < media_ref.start_frame
and conformed_src_in.to_frames() < media_ref.start_frame
):
media_in = 0
media_in = otio.opentime.RationalTime(
0, rate=available_range_rate
)
src_offset_in = otio_range.start_time - media_in
frame_in = otio.opentime.RationalTime.from_frames(
media_in_trimmed - media_in + media_ref.start_frame,
media_ref.start_frame + src_offset_in.to_frames(),
rate=available_range_rate,
).to_frames()
frame_out = otio.opentime.RationalTime.from_frames(
media_out_trimmed - media_in + media_ref.start_frame,
frame_in + otio_range.duration.to_frames() - 1,
rate=available_range_rate,
).to_frames()
@ -261,21 +275,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
media_ref = otio_clip.media_reference
is_input_sequence = is_clip_from_media_sequence(otio_clip)
# Temporary.
# Some AYON custom OTIO exporter were implemented with relative
# source range for image sequence. Following code maintain
# backward-compatibility by adjusting available range
# while we are updating those.
if (
is_input_sequence
and available_range.start_time.to_frames() == media_ref.start_frame
and source_range.start_time.to_frames() < media_ref.start_frame
):
available_range = _ot.TimeRange(
_ot.RationalTime(0, rate=available_range_rate),
available_range.duration,
)
# Conform source range bounds to available range rate
# .e.g. embedded TC of (3600 sec/ 1h), duration 100 frames
#
@ -320,6 +319,22 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
else:
conformed_source_range = source_range
# Temporary.
# Some AYON custom OTIO exporter were implemented with relative
# source range for image sequence. Following code maintain
# backward-compatibility by adjusting available range
# while we are updating those.
if (
is_input_sequence
and available_range.start_time.to_frames() == media_ref.start_frame
and conformed_source_range.start_time.to_frames() <
media_ref.start_frame
):
available_range = _ot.TimeRange(
_ot.RationalTime(0, rate=available_range_rate),
available_range.duration,
)
# modifiers
time_scalar = 1.
offset_in = 0
@ -374,31 +389,47 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
offset_in, offset_out = offset_out, offset_in
handle_start, handle_end = handle_end, handle_start
# compute retimed range
media_in_trimmed = conformed_source_range.start_time.value + offset_in
media_out_trimmed = media_in_trimmed + (
(
conformed_source_range.duration.value
* abs(time_scalar)
+ offset_out
) - 1
)
media_in = available_range.start_time.value
media_out = available_range.end_time_inclusive().value
# If media source is an image sequence, returned
# mediaIn/mediaOut have to correspond
# to frame numbers from source sequence.
if is_input_sequence:
src_in = conformed_source_range.start_time
src_duration = conformed_source_range.duration
offset_in = otio.opentime.RationalTime(offset_in, rate=src_in.rate)
offset_duration = otio.opentime.RationalTime(
offset_out,
rate=src_duration.rate
)
trim_range = otio.opentime.TimeRange(
start_time=src_in + offset_in,
duration=src_duration + offset_duration
)
# preserve discrete frame numbers
media_in_trimmed, media_out_trimmed = remap_range_on_file_sequence(
otio_clip,
(media_in_trimmed, media_out_trimmed)
trim_range,
)
media_in = media_ref.start_frame
media_out = media_in + available_range.duration.to_frames() - 1
else:
# compute retimed range
media_in_trimmed = conformed_source_range.start_time.value + offset_in
media_out_trimmed = media_in_trimmed + (
(
conformed_source_range.duration.value
* abs(time_scalar)
+ offset_out
) - 1
)
media_in = available_range.start_time.value
media_out = available_range.end_time_inclusive().value
# adjust available handles if needed
if (media_in_trimmed - media_in) < handle_start:
handle_start = max(0, media_in_trimmed - media_in)

View file

@ -708,6 +708,7 @@ def get_instance_staging_dir(instance):
project_settings=context.data["project_settings"],
template_data=template_data,
always_return_path=True,
username=context.data["user"],
)
staging_dir_path = staging_dir_info.directory
@ -716,7 +717,8 @@ def get_instance_staging_dir(instance):
os.makedirs(staging_dir_path, exist_ok=True)
instance.data.update({
"stagingDir": staging_dir_path,
"stagingDir_persistent": staging_dir_info.persistent,
"stagingDir_persistent": staging_dir_info.is_persistent,
"stagingDir_is_custom": staging_dir_info.is_custom
})
return staging_dir_path

View file

@ -1,3 +1,6 @@
import logging
import warnings
from typing import Optional, Dict, Any
from dataclasses import dataclass
from ayon_core.lib import Logger, filter_profiles
@ -11,20 +14,41 @@ from .tempdir import get_temp_dir
@dataclass
class StagingDir:
directory: str
persistent: bool
is_persistent: bool
# Whether the staging dir is a custom staging dir
is_custom: bool
def __setattr__(self, key, value):
if key == "persistent":
warnings.warn(
"'StagingDir.persistent' is deprecated."
" Use 'StagingDir.is_persistent' instead.",
DeprecationWarning
)
key = "is_persistent"
super().__setattr__(key, value)
@property
def persistent(self):
warnings.warn(
"'StagingDir.persistent' is deprecated."
" Use 'StagingDir.is_persistent' instead.",
DeprecationWarning
)
return self.is_persistent
def get_staging_dir_config(
project_name,
task_type,
task_name,
product_type,
product_name,
host_name,
project_settings=None,
anatomy=None,
log=None,
):
project_name: str,
task_type: Optional[str],
task_name: Optional[str],
product_type: str,
product_name: str,
host_name: str,
project_settings: Optional[Dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
log: Optional[logging.Logger] = None,
) -> Optional[Dict[str, Any]]:
"""Get matching staging dir profile.
Args:
@ -75,7 +99,6 @@ def get_staging_dir_config(
# get template from template name
template_name = profile["template_name"]
_validate_template_name(project_name, template_name, anatomy)
template = anatomy.get_template_item("staging", template_name)
@ -92,35 +115,23 @@ def get_staging_dir_config(
return {"template": template, "persistence": data_persistence}
def _validate_template_name(project_name, template_name, anatomy):
"""Check that staging dir section with appropriate template exist.
Raises:
ValueError - if misconfigured template
"""
if template_name not in anatomy.templates["staging"]:
raise ValueError(
f'Anatomy of project "{project_name}" does not have set'
f' "{template_name}" template key at Staging Dir category!'
)
def get_staging_dir_info(
project_entity,
folder_entity,
task_entity,
product_type,
product_name,
host_name,
anatomy=None,
project_settings=None,
template_data=None,
always_return_path=True,
force_tmp_dir=False,
logger=None,
prefix=None,
suffix=None,
):
project_entity: Dict[str, Any],
folder_entity: Optional[Dict[str, Any]],
task_entity: Optional[Dict[str, Any]],
product_type: str,
product_name: str,
host_name: str,
anatomy: Optional[Anatomy] = None,
project_settings: Optional[Dict[str, Any]] = None,
template_data: Optional[Dict[str, Any]] = None,
always_return_path: bool = True,
force_tmp_dir: bool = False,
logger: Optional[logging.Logger] = None,
prefix: Optional[str] = None,
suffix: Optional[str] = None,
username: Optional[str] = None,
) -> Optional[StagingDir]:
"""Get staging dir info data.
If `force_temp` is set, staging dir will be created as tempdir.
@ -147,6 +158,7 @@ def get_staging_dir_info(
logger (Optional[logging.Logger]): Logger instance.
prefix (Optional[str]) Optional prefix for staging dir name.
suffix (Optional[str]): Optional suffix for staging dir name.
username (Optional[str]): AYON Username.
Returns:
Optional[StagingDir]: Staging dir info data
@ -160,16 +172,22 @@ def get_staging_dir_info(
)
if force_tmp_dir:
return get_temp_dir(
project_name=project_entity["name"],
anatomy=anatomy,
prefix=prefix,
suffix=suffix,
return StagingDir(
get_temp_dir(
project_name=project_entity["name"],
anatomy=anatomy,
prefix=prefix,
suffix=suffix,
),
is_persistent=False,
is_custom=False
)
# making few queries to database
ctx_data = get_template_data(
project_entity, folder_entity, task_entity, host_name
project_entity, folder_entity, task_entity, host_name,
settings=project_settings,
username=username
)
# add additional data
@ -204,7 +222,8 @@ def get_staging_dir_info(
dir_template = staging_dir_config["template"]["directory"]
return StagingDir(
dir_template.format_strict(ctx_data),
staging_dir_config["persistence"],
is_persistent=staging_dir_config["persistence"],
is_custom=True
)
# no config found but force an output
@ -216,7 +235,8 @@ def get_staging_dir_info(
prefix=prefix,
suffix=suffix,
),
False,
is_persistent=False,
is_custom=False
)
return None

View file

@ -4,7 +4,7 @@ from ayon_core.settings import get_studio_settings
from ayon_core.lib.local_settings import get_ayon_username
def get_general_template_data(settings=None):
def get_general_template_data(settings=None, username=None):
"""General template data based on system settings or machine.
Output contains formatting keys:
@ -14,17 +14,22 @@ def get_general_template_data(settings=None):
Args:
settings (Dict[str, Any]): Studio or project settings.
username (Optional[str]): AYON Username.
"""
if not settings:
settings = get_studio_settings()
if username is None:
username = get_ayon_username()
core_settings = settings["core"]
return {
"studio": {
"name": core_settings["studio_name"],
"code": core_settings["studio_code"]
},
"user": get_ayon_username()
"user": username
}
@ -145,6 +150,7 @@ def get_template_data(
task_entity=None,
host_name=None,
settings=None,
username=None
):
"""Prepare data for templates filling from entered documents and info.
@ -167,12 +173,13 @@ def get_template_data(
host_name (Optional[str]): Used to fill '{app}' key.
settings (Union[Dict, None]): Prepared studio or project settings.
They're queried if not passed (may be slower).
username (Optional[str]): AYON Username.
Returns:
Dict[str, Any]: Data prepared for filling workdir template.
"""
template_data = get_general_template_data(settings)
template_data = get_general_template_data(settings, username=username)
template_data.update(get_project_template_data(project_entity))
if folder_entity:
template_data.update(get_folder_template_data(

View file

@ -24,6 +24,7 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
# NOTE we should use 'context.data["user"]' but that has higher
# order.
("AYON_USERNAME", get_ayon_username()),
("AYON_HOST_NAME", context.data["hostName"]),
):
if value:
self.log.debug(f"Setting job env: {key}: {value}")

View file

@ -178,7 +178,10 @@ class CollectOtioSubsetResources(
repre = self._create_representation(
frame_start, frame_end, collection=collection)
if "review" in instance.data["families"]:
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end, collection=collection,
delete=True, review=True)
@ -197,7 +200,10 @@ class CollectOtioSubsetResources(
repre = self._create_representation(
frame_start, frame_end, file=filename, trim=_trim)
if "review" in instance.data["families"]:
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end,
file=filename, delete=True, review=True)

View file

@ -93,8 +93,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
# now we can just add instances from json file and we are done
any_staging_dir_persistent = False
for instance_data in data.get("instances"):
for instance_data in data["instances"]:
self.log.debug(" - processing instance for {}".format(
instance_data.get("productName")))
instance = self._context.create_instance(
@ -105,7 +104,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
instance.data.update(instance_data)
# stash render job id for later validation
instance.data["render_job_id"] = data.get("job").get("_id")
instance.data["publishJobMetadata"] = data
# TODO remove 'render_job_id' here and rather use
# 'publishJobMetadata' where is needed.
# - this is deadline specific
instance.data["render_job_id"] = data.get("job", {}).get("_id")
staging_dir_persistent = instance.data.get(
"stagingDir_persistent", False
)

View file

@ -66,7 +66,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"yeticacheUE",
"tycache",
"usd",
"oxrig"
"oxrig",
"sbsar",
]
def process(self, instance):

View file

@ -209,13 +209,9 @@ class ExtractOTIOReview(
# File sequence way
if is_sequence:
# Remap processing range to input file sequence.
processing_range_as_frames = (
processing_range.start_time.to_frames(),
processing_range.end_time_inclusive().to_frames()
)
first, last = remap_range_on_file_sequence(
r_otio_cl,
processing_range_as_frames,
processing_range,
)
input_fps = processing_range.start_time.rate

View file

@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
label = "Validate File Saved"
order = pyblish.api.ValidatorOrder - 0.1
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter",
"cinema4d"]
"cinema4d", "silhouette"]
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
def process(self, context):

View file

@ -23,6 +23,9 @@ Enabled vs Disabled logic in most of stylesheets
font-family: "Noto Sans";
font-weight: 450;
outline: none;
/* Define icon size to fix size issues for most of DCCs */
icon-size: 16px;
}
QWidget {

View file

@ -265,7 +265,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate):
if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
rect = QtCore.QRectF(
option.rect.x(), option.rect.height(), 5, 5)
option.rect.x(), option.rect.y() + option.rect.height(), 5, 5)
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(QtGui.QColor(200, 0, 0))
painter.drawEllipse(rect)

View file

@ -197,7 +197,7 @@ class ConvertorItemsGroupWidget(BaseGroupWidget):
else:
widget = ConvertorItemCardWidget(item, self)
widget.selected.connect(self._on_widget_selection)
widget.double_clicked(self.double_clicked)
widget.double_clicked.connect(self.double_clicked)
self._widgets_by_id[item.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1

View file

@ -28,7 +28,7 @@ def find_free_port(
exclude_ports (list, tuple, set): List of ports that won't be
checked form entered range.
host (str): Host where will check for free ports. Set to
"localhost" by default.
"127.0.0.1" by default.
"""
if port_from is None:
port_from = 8079
@ -42,7 +42,7 @@ def find_free_port(
# Default host is localhost but it is possible to look for other hosts
if host is None:
host = "localhost"
host = "127.0.0.1"
found_port = None
while True:
@ -78,7 +78,7 @@ class WebServerManager:
self._log = None
self.port = port or 8079
self.host = host or "localhost"
self.host = host or "127.0.0.1"
self.on_stop_callbacks = []

View file

@ -1,7 +1,6 @@
from ayon_core.resources import get_image_path
from ayon_core.tools.flickcharm import FlickCharm
from qtpy import QtWidgets, QtCore, QtGui, QtSvg
from qtpy import QtWidgets, QtCore, QtGui
class DeselectableTreeView(QtWidgets.QTreeView):
@ -19,48 +18,6 @@ class DeselectableTreeView(QtWidgets.QTreeView):
QtWidgets.QTreeView.mousePressEvent(self, event)
class TreeViewSpinner(QtWidgets.QTreeView):
size = 160
def __init__(self, parent=None):
super(TreeViewSpinner, self).__init__(parent=parent)
loading_image_path = get_image_path("spinner-200.svg")
self.spinner = QtSvg.QSvgRenderer(loading_image_path)
self.is_loading = False
self.is_empty = True
def paint_loading(self, event):
rect = event.rect()
rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
rect.moveTo(
rect.x() + rect.width() / 2 - self.size / 2,
rect.y() + rect.height() / 2 - self.size / 2
)
rect.setSize(QtCore.QSizeF(self.size, self.size))
painter = QtGui.QPainter(self.viewport())
self.spinner.render(painter, rect)
def paint_empty(self, event):
painter = QtGui.QPainter(self.viewport())
rect = event.rect()
rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
qtext_opt = QtGui.QTextOption(
QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
)
painter.drawText(rect, "No Data", qtext_opt)
def paintEvent(self, event):
if self.is_loading:
self.paint_loading(event)
elif self.is_empty:
self.paint_empty(event)
else:
super(TreeViewSpinner, self).paintEvent(event)
class TreeView(QtWidgets.QTreeView):
"""Ultimate TreeView with flick charm and double click signals.

View file

@ -184,9 +184,10 @@ class WorkareaModel:
return items
for filename in os.listdir(workdir):
# We want to support both files and folders. e.g. Silhoutte uses
# folders as its project files. So we do not check whether it is
# a file or not.
filepath = os.path.join(workdir, filename)
if not os.path.isfile(filepath):
continue
ext = os.path.splitext(filename)[1].lower()
if ext not in self._extensions:

View file

@ -136,6 +136,8 @@ class FilesWidget(QtWidgets.QWidget):
# Initial setup
workarea_btn_open.setEnabled(False)
workarea_btn_browse.setEnabled(False)
workarea_btn_save.setEnabled(False)
published_btn_copy_n_open.setEnabled(False)
published_btn_change_context.setEnabled(False)
published_btn_cancel.setVisible(False)
@ -278,8 +280,9 @@ class FilesWidget(QtWidgets.QWidget):
self._published_btn_change_context.setEnabled(enabled)
def _update_workarea_btns_state(self):
enabled = self._is_save_enabled
enabled = self._is_save_enabled and self._valid_selected_context
self._workarea_btn_save.setEnabled(enabled)
self._workarea_btn_browse.setEnabled(self._valid_selected_context)
def _on_published_repre_changed(self, event):
self._valid_representation_id = event["representation_id"] is not None
@ -294,6 +297,7 @@ class FilesWidget(QtWidgets.QWidget):
and self._selected_task_id is not None
)
self._update_published_btns_state()
self._update_workarea_btns_state()
def _on_published_save_clicked(self):
result = self._exec_save_as_dialog()

View file

@ -113,6 +113,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.addWidget(pages_widget, 1)
main_layout.setContentsMargins(0, 0, 0, 0)
overlay_messages_widget = MessageOverlayObject(self)
overlay_invalid_host = InvalidHostOverlay(self)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.0.12+dev"
__version__ = "1.0.15-dev"