Merge branch 'develop' into bugfix/1139-version_up_current_workfile-doesnt-return-the-first-workfile-name-if-there-are-no-workfiles

This commit is contained in:
Mustafa Jafar 2025-02-11 20:30:56 +02:00 committed by GitHub
commit 600c6e6207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3516 additions and 181 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

@ -108,21 +108,29 @@ def run_subprocess(*args, **kwargs):
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
# Escape parentheses for bash
# Escape special characters in certain shells
if (
kwargs.get("shell") is True
and len(args) == 1
and isinstance(args[0], str)
and os.getenv("SHELL") in ("/bin/bash", "/bin/sh")
):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg, )
# Escape parentheses for bash
if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg,)
# Escape & on Windows in shell with `cmd.exe` using ^&
elif (
platform.system().lower() == "windows"
and os.getenv("COMSPEC").endswith("cmd.exe")
):
new_arg = args[0].replace("&", "^&")
args = (new_arg, )
# Get environents from kwarg or use current process environments if were
# Get environments from kwarg or use current process environments if were
# not passed.
env = kwargs.get("env") or os.environ
# Make sure environment contains only strings

View file

@ -587,8 +587,8 @@ class FormattingPart:
if sub_key < 0:
sub_key = len(value) + sub_key
invalid = 0 > sub_key < len(data)
if invalid:
valid = 0 <= sub_key < len(value)
if not valid:
used_keys.append(sub_key)
missing_key = True
break

View file

@ -29,6 +29,7 @@ from ayon_core.lib.events import QueuedEventSystem
from ayon_core.lib.attribute_definitions import get_default_values
from ayon_core.host import IPublishHost, IWorkfileHost
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.plugin_discover import DiscoverResult
from .exceptions import (
@ -480,6 +481,36 @@ class CreateContext:
self.get_current_project_name())
return self._current_project_settings
def get_template_data(
self, folder_path: Optional[str], task_name: Optional[str]
) -> Dict[str, Any]:
"""Prepare template data for given context.
Method is using cached entities and settings to prepare template data.
Args:
folder_path (Optional[str]): Folder path.
task_name (Optional[str]): Task name.
Returns:
dict[str, Any]: Template data.
"""
project_entity = self.get_current_project_entity()
folder_entity = task_entity = None
if folder_path:
folder_entity = self.get_folder_entity(folder_path)
if task_name and folder_entity:
task_entity = self.get_task_entity(folder_path, task_name)
return get_template_data(
project_entity,
folder_entity,
task_entity,
host_name=self.host_name,
settings=self.get_current_project_settings(),
)
@property
def context_has_changed(self):
"""Host context has changed.

View file

@ -562,6 +562,10 @@ class BaseCreator(ABC):
instance
)
cur_project_name = self.create_context.get_current_project_name()
if not project_entity and project_name == cur_project_name:
project_entity = self.create_context.get_current_project_entity()
return get_product_name(
project_name,
task_name,
@ -858,18 +862,30 @@ class Creator(BaseCreator):
["CollectAnatomyInstanceData"]
["follow_workfile_version"]
)
follow_version_hosts = (
publish_settings
["CollectSceneVersion"]
["hosts"]
)
current_host = create_ctx.host.name
follow_workfile_version = (
follow_workfile_version and
current_host in follow_version_hosts
)
# Gather version number provided from the instance.
current_workfile = create_ctx.get_current_workfile_path()
version = instance.get("version")
# If follow workfile, gather version from workfile path.
if version is None and follow_workfile_version:
current_workfile = self.create_context.get_current_workfile_path()
if version is None and follow_workfile_version and current_workfile:
workfile_version = get_version_from_path(current_workfile)
version = int(workfile_version)
if workfile_version is not None:
version = int(workfile_version)
# Fill-up version with next version available.
elif version is None:
if version is None:
versions = self.get_next_versions_for_instances(
[instance]
)
@ -916,6 +932,7 @@ class Creator(BaseCreator):
instance.transient_data.update({
"stagingDir": staging_dir_path,
"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

@ -9,7 +9,7 @@ import os
import logging
import collections
from ayon_core.pipeline.constants import AVALON_INSTANCE_ID
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
from .product_name import get_product_name
@ -34,7 +34,7 @@ class LegacyCreator:
# Default data
self.data = collections.OrderedDict()
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
self.data["id"] = AVALON_INSTANCE_ID
self.data["id"] = AYON_INSTANCE_ID
self.data["productType"] = self.product_type
self.data["folderPath"] = folder_path
self.data["productName"] = name

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
import typing
from typing import Optional, Dict, List, Any
from ayon_core.lib.attribute_definitions import (
@ -17,6 +18,9 @@ from ayon_core.pipeline import (
from .exceptions import ImmutableKeyError
from .changes import TrackChangesItem
if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
class ConvertorItem:
"""Item representing convertor plugin.
@ -444,10 +448,11 @@ class CreatedInstance:
def __init__(
self,
product_type,
product_name,
data,
creator,
product_type: str,
product_name: str,
data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
):
self._creator = creator
creator_identifier = creator.identifier
@ -462,7 +467,9 @@ class CreatedInstance:
self._members = []
# Data that can be used for lifetime of object
self._transient_data = {}
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@ -492,7 +499,7 @@ class CreatedInstance:
item_id = data.get("id")
# TODO use only 'AYON_INSTANCE_ID' when all hosts support it
if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}:
item_id = AVALON_INSTANCE_ID
item_id = AYON_INSTANCE_ID
self._data["id"] = item_id
self._data["productType"] = product_type
self._data["productName"] = product_name
@ -787,16 +794,26 @@ class CreatedInstance:
self._create_context.instance_create_attr_defs_changed(self.id)
@classmethod
def from_existing(cls, instance_data, creator):
def from_existing(
cls,
instance_data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
) -> "CreatedInstance":
"""Convert instance data from workfile to CreatedInstance.
Args:
instance_data (Dict[str, Any]): Data in a structure ready for
'CreatedInstance' object.
creator (BaseCreator): Creator plugin which is creating the
instance of for which the instance belong.
"""
instance of for which the instance belongs.
transient_data (Optional[dict[str, Any]]): Instance transient
data.
Returns:
CreatedInstance: Instance object.
"""
instance_data = copy.deepcopy(instance_data)
product_type = instance_data.get("productType")
@ -809,7 +826,11 @@ class CreatedInstance:
product_name = instance_data.get("subset")
return cls(
product_type, product_name, instance_data, creator
product_type,
product_name,
instance_data,
creator,
transient_data=transient_data,
)
def attribute_value_changed(self, key, changes):

View file

@ -1,6 +1,7 @@
import os
import re
import clique
import math
import opentimelineio as otio
from opentimelineio import opentime as _ot
@ -256,8 +257,14 @@ def remap_range_on_file_sequence(otio_clip, otio_range):
rate=available_range_rate,
).to_frames()
# e.g.:
# duration = 10 frames at 24fps
# if frame_in = 1001 then
# frame_out = 1010
offset_duration = max(0, otio_range.duration.to_frames() - 1)
frame_out = otio.opentime.RationalTime.from_frames(
frame_in + otio_range.duration.to_frames() - 1,
frame_in + offset_duration,
rate=available_range_rate,
).to_frames()
@ -337,8 +344,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
# modifiers
time_scalar = 1.
offset_in = 0
offset_out = 0
time_warp_nodes = []
# Check for speed effects and adjust playback speed accordingly
@ -369,24 +374,15 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
tw_node.update(metadata)
tw_node["lookup"] = list(lookup)
# get first and last frame offsets
offset_in += lookup[0]
offset_out += lookup[-1]
# add to timewarp nodes
time_warp_nodes.append(tw_node)
# multiply by time scalar
offset_in *= time_scalar
offset_out *= time_scalar
# scale handles
handle_start *= abs(time_scalar)
handle_end *= abs(time_scalar)
# flip offset and handles if reversed speed
if time_scalar < 0:
offset_in, offset_out = offset_out, offset_in
handle_start, handle_end = handle_end, handle_start
# If media source is an image sequence, returned
@ -395,17 +391,19 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
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
src_duration = math.ceil(
otio_clip.source_range.duration.value
* abs(time_scalar)
)
retimed_duration = otio.opentime.RationalTime(
src_duration,
otio_clip.source_range.duration.rate
)
retimed_duration = retimed_duration.rescaled_to(src_in.rate)
trim_range = otio.opentime.TimeRange(
start_time=src_in + offset_in,
duration=src_duration + offset_duration
start_time=src_in,
duration=retimed_duration,
)
# preserve discrete frame numbers
@ -418,18 +416,92 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
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_trimmed = conformed_source_range.start_time.value
offset_duration = (
conformed_source_range.duration.value
* abs(time_scalar)
)
# Offset duration by 1 for media out frame
# - only if duration is not single frame (start frame != end frame)
if offset_duration > 0:
offset_duration -= 1
media_out_trimmed = media_in_trimmed + offset_duration
media_in = available_range.start_time.value
media_out = available_range.end_time_inclusive().value
if time_warp_nodes:
# Naive approach: Resolve consecutive timewarp(s) on range,
# then check if plate range has to be extended beyond source range.
in_frame = media_in_trimmed
frame_range = [in_frame]
for _ in range(otio_clip.source_range.duration.to_frames() - 1):
in_frame += time_scalar
frame_range.append(in_frame)
# Different editorial DCC might have different TimeWarp logic.
# The following logic assumes that the "lookup" list values are
# frame offsets relative to the current source frame number.
#
# media_source_range |______1_____|______2______|______3______|
#
# media_retimed_range |______2_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
for tw_idx, tw in enumerate(time_warp_nodes):
for idx, frame_number in enumerate(frame_range):
# First timewarp, apply on media range
if tw_idx == 0:
frame_range[idx] = round(
frame_number +
(tw["lookup"][idx] * time_scalar)
)
# Consecutive timewarp, apply on the previous result
else:
new_idx = round(idx + tw["lookup"][idx])
if 0 <= new_idx < len(frame_range):
frame_range[idx] = frame_range[new_idx]
continue
# TODO: implementing this would need to actually have
# retiming engine resolve process within AYON,
# resolving wraps as curves, then projecting
# those into the previous media_range.
raise NotImplementedError(
"Unsupported consecutive timewarps "
"(out of computed range)"
)
# adjust range if needed
media_in_trimmed_before_tw = media_in_trimmed
media_in_trimmed = max(min(frame_range), media_in)
media_out_trimmed = min(max(frame_range), media_out)
# If TimeWarp changes the first frame of the soure range,
# we need to offset the first TimeWarp values accordingly.
#
# expected_range |______2_____|______2______|______3______|
#
# EDITORIAL
# media_source_range |______1_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
#
# EXTRACTED PLATE
# plate_range |______2_____|______3______|_ _ _ _ _ _ _|
#
# expected TimeWarp 0 -1 -1
if media_in_trimmed != media_in_trimmed_before_tw:
offset = media_in_trimmed_before_tw - media_in_trimmed
offset *= 1.0 / time_scalar
time_warp_nodes[0]["lookup"] = [
value + offset
for value in time_warp_nodes[0]["lookup"]
]
# adjust available handles if needed
if (media_in_trimmed - media_in) < handle_start:
handle_start = max(0, media_in_trimmed - media_in)
@ -448,16 +520,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
"retime": True,
"speed": time_scalar,
"timewarps": time_warp_nodes,
"handleStart": int(handle_start),
"handleEnd": int(handle_end)
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end)
}
}
returning_dict = {
"mediaIn": media_in_trimmed,
"mediaOut": media_out_trimmed,
"handleStart": int(handle_start),
"handleEnd": int(handle_end),
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end),
"speed": time_scalar
}

View file

@ -464,6 +464,12 @@ def filter_pyblish_plugins(plugins):
if getattr(plugin, "enabled", True) is False:
plugins.remove(plugin)
# Pyblish already operated a filter based on host.
# But applying settings might have changed "hosts"
# value in plugin so re-filter.
elif not pyblish.plugin.host_is_compatible(plugin):
plugins.remove(plugin)
def get_errored_instances_from_context(context, plugin=None):
"""Collect failed instances from pyblish context.
@ -708,6 +714,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

View file

@ -292,6 +292,9 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
```
"""
# Allow exposing tooltip from class with `optional_tooltip` attribute
optional_tooltip: Optional[str] = None
@classmethod
def get_attribute_defs(cls):
"""Attribute definitions based on plugin's optional attribute."""
@ -304,8 +307,14 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
active = getattr(cls, "active", True)
# Return boolean stored under 'active' key with label of the class name
label = cls.label or cls.__name__
return [
BoolDef("active", default=active, label=label)
BoolDef(
"active",
default=active,
label=label,
tooltip=cls.optional_tooltip,
)
]
def is_active(self, data):

View file

@ -130,6 +130,7 @@ def get_staging_dir_info(
logger: Optional[logging.Logger] = None,
prefix: Optional[str] = None,
suffix: Optional[str] = None,
username: Optional[str] = None,
) -> Optional[StagingDir]:
"""Get staging dir info data.
@ -157,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
@ -183,7 +185,9 @@ def get_staging_dir_info(
# 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

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

@ -36,6 +36,16 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
# optionally get `reviewTrack`
review_track_name = instance.data.get("reviewTrack")
# [clip_media] setting:
# Extract current clip source range as reviewable.
# Flag review content from otio_clip.
if not review_track_name and "review" in instance.data["families"]:
otio_review_clips = [otio_clip]
# skip if no review track available
elif not review_track_name:
return
# generate range in parent
otio_tl_range = otio_clip.range_in_parent()
@ -43,12 +53,14 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
clip_frame_end = int(
otio_tl_range.start_time.value + otio_tl_range.duration.value)
# skip if no review track available
if not review_track_name:
return
# loop all tracks and match with name in `reviewTrack`
for track in otio_timeline.tracks:
# No review track defined, skip the loop
if review_track_name is None:
break
# Not current review track, skip it.
if review_track_name != track.name:
continue

View file

@ -6,6 +6,7 @@ Provides:
instance -> otioReviewClips
"""
import os
import math
import clique
import pyblish.api
@ -69,9 +70,17 @@ class CollectOtioSubsetResources(
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
# break down into variables
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
# break down into variables as rounded frame numbers
#
# 0 1 2 3 4
# |-------------|---------------|--------------|-------------|
# |_______________media range_______________|
# 0.6 3.2
#
# As rounded frames, media_in = 0 and media_out = 4
media_in = math.floor(retimed_attributes["mediaIn"])
media_out = math.ceil(retimed_attributes["mediaOut"])
handle_start = int(retimed_attributes["handleStart"])
handle_end = int(retimed_attributes["handleEnd"])
@ -149,7 +158,6 @@ class CollectOtioSubsetResources(
self.log.info(
"frame_start-frame_end: {}-{}".format(frame_start, frame_end))
review_repre = None
if is_sequence:
# file sequence way
@ -174,17 +182,18 @@ class CollectOtioSubsetResources(
path, trimmed_media_range_h, metadata)
self.staging_dir, collection = collection_data
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
if len(collection.indexes) > 1:
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
else:
filename = tuple(collection)[0]
self.log.debug(filename)
# TODO: discuss this, it erases frame number.
repre = self._create_representation(
frame_start, frame_end, file=filename)
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)
else:
_trim = False
@ -200,13 +209,6 @@ class CollectOtioSubsetResources(
repre = self._create_representation(
frame_start, frame_end, file=filename, trim=_trim)
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)
instance.data["originalDirname"] = self.staging_dir
@ -219,9 +221,6 @@ class CollectOtioSubsetResources(
instance.data["representations"].append(repre)
# add review representation to instance data
if review_repre:
instance.data["representations"].append(review_repre)
self.log.debug(instance.data)

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

@ -14,23 +14,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder
label = 'Collect Scene Version'
# configurable in Settings
hosts = [
"aftereffects",
"blender",
"celaction",
"fusion",
"harmony",
"hiero",
"houdini",
"maya",
"max",
"nuke",
"photoshop",
"resolve",
"tvpaint",
"motionbuilder",
"substancepainter"
]
hosts = ["*"]
# in some cases of headless publishing (for example webpublisher using PS)
# you want to ignore version from name and let integrate use next version

View file

@ -147,7 +147,6 @@ class ExtractOTIOReview(
self.actual_fps = available_range.duration.rate
start = src_range.start_time.rescaled_to(self.actual_fps)
duration = src_range.duration.rescaled_to(self.actual_fps)
src_frame_start = src_range.start_time.to_frames()
# Temporary.
# Some AYON custom OTIO exporter were implemented with
@ -157,7 +156,7 @@ class ExtractOTIOReview(
if (
is_clip_from_media_sequence(r_otio_cl)
and available_range_start_frame == media_ref.start_frame
and src_frame_start < media_ref.start_frame
and start.to_frames() < media_ref.start_frame
):
available_range = otio.opentime.TimeRange(
otio.opentime.RationalTime(0, rate=self.actual_fps),
@ -321,6 +320,9 @@ class ExtractOTIOReview(
end = max(collection.indexes)
files = [f for f in collection]
# single frame sequence
if len(files) == 1:
files = files[0]
ext = collection.format("{tail}")
representation_data.update({
"name": ext[1:],

View file

@ -35,6 +35,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"resolve",
"traypublisher",
"substancepainter",
"substancedesigner",
"nuke",
"aftereffects",
"unreal",

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 {
@ -1168,6 +1171,8 @@ ValidationArtistMessage QLabel {
#PublishLogMessage {
font-family: "Noto Sans Mono";
border: none;
padding: 0;
}
#PublishInstanceLogsLabel {

View file

@ -1117,6 +1117,57 @@ class LogIconFrame(QtWidgets.QFrame):
painter.end()
class LogItemMessage(QtWidgets.QTextEdit):
def __init__(self, msg, parent):
super().__init__(parent)
# Set as plain text to propagate new line characters
self.setPlainText(msg)
self.setObjectName("PublishLogMessage")
self.setReadOnly(True)
self.setFrameStyle(QtWidgets.QFrame.NoFrame)
self.setLineWidth(0)
self.setMidLineWidth(0)
pal = self.palette()
pal.setColor(QtGui.QPalette.Base, QtCore.Qt.transparent)
self.setPalette(pal)
self.setContentsMargins(0, 0, 0, 0)
viewport = self.viewport()
viewport.setContentsMargins(0, 0, 0, 0)
self.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction)
self.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
self.setLineWrapMode(QtWidgets.QTextEdit.WidgetWidth)
self.setWordWrapMode(
QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere
)
self.setSizePolicy(
QtWidgets.QSizePolicy.Preferred,
QtWidgets.QSizePolicy.Maximum
)
document = self.document()
document.documentLayout().documentSizeChanged.connect(
self._adjust_minimum_size
)
document.setDocumentMargin(0.0)
self._height = None
def _adjust_minimum_size(self, size):
self._height = size.height() + (2 * self.frameWidth())
self.updateGeometry()
def sizeHint(self):
size = super().sizeHint()
if self._height is not None:
size.setHeight(self._height)
return size
def minimumSizeHint(self):
return self.sizeHint()
class LogItemWidget(QtWidgets.QWidget):
log_level_to_flag = {
10: LOG_DEBUG_VISIBLE,
@ -1132,12 +1183,7 @@ class LogItemWidget(QtWidgets.QWidget):
type_flag, level_n = self._get_log_info(log)
icon_label = LogIconFrame(
self, log["type"], level_n, log.get("is_validation_error"))
message_label = QtWidgets.QLabel(log["msg"].rstrip(), self)
message_label.setObjectName("PublishLogMessage")
message_label.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction)
message_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
message_label.setWordWrap(True)
message_label = LogItemMessage(log["msg"].rstrip(), self)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
@ -1290,6 +1336,7 @@ class InstanceLogsWidget(QtWidgets.QWidget):
label_widget = QtWidgets.QLabel(instance.label, self)
label_widget.setObjectName("PublishInstanceLogsLabel")
label_widget.setWordWrap(True)
logs_grid = LogsWithIconsView(instance.logs, self)
layout = QtWidgets.QVBoxLayout(self)
@ -1329,9 +1376,11 @@ class InstancesLogsView(QtWidgets.QFrame):
content_wrap_widget = QtWidgets.QWidget(scroll_area)
content_wrap_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_wrap_widget.setMinimumWidth(80)
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)

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.1.0+dev"