mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-27 06:12:19 +01:00
Merge branch 'develop' into bugfix/default-publish-iterator
This commit is contained in:
commit
d360ac9ba0
21 changed files with 1063 additions and 258 deletions
|
|
@ -20,6 +20,7 @@ from ayon_core.lib import (
|
|||
Logger,
|
||||
is_dev_mode_enabled,
|
||||
get_launcher_storage_dir,
|
||||
is_headless_mode_enabled,
|
||||
)
|
||||
from ayon_core.settings import get_studio_settings
|
||||
|
||||
|
|
@ -80,36 +81,41 @@ class ProcessPreparationError(Exception):
|
|||
|
||||
|
||||
class ProcessContext:
|
||||
"""Context of child process.
|
||||
"""Hold context of process that is going to be started.
|
||||
|
||||
Notes:
|
||||
This class is used to pass context to child process. It can be used
|
||||
to use different behavior of addon based on information in
|
||||
the context.
|
||||
The context can be enhanced in future versions.
|
||||
Right now the context is simple, having information about addon that wants
|
||||
to trigger preparation and possibly project name for which it should
|
||||
happen.
|
||||
|
||||
Preparation for process can be required for ayon-core or any other addon.
|
||||
It can be, change of environment variables, or request login to
|
||||
a project management.
|
||||
|
||||
At the moment of creation is 'ProcessContext' only data holder, but that
|
||||
might change in future if there will be need.
|
||||
|
||||
Args:
|
||||
addon_name (Optional[str]): Addon name which triggered process.
|
||||
addon_version (Optional[str]): Addon version which triggered process.
|
||||
addon_name (str): Addon name which triggered process.
|
||||
addon_version (str): Addon version which triggered process.
|
||||
project_name (Optional[str]): Project name. Can be filled in case
|
||||
process is triggered for specific project. Some addons can have
|
||||
different behavior based on project.
|
||||
headless (Optional[bool]): Is process running in headless mode.
|
||||
different behavior based on project. Value is NOT autofilled.
|
||||
headless (Optional[bool]): Is process running in headless mode. Value
|
||||
is filled with value based on state set in AYON launcher.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
addon_name: Optional[str] = None,
|
||||
addon_version: Optional[str] = None,
|
||||
addon_name: str,
|
||||
addon_version: str,
|
||||
project_name: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if headless is None:
|
||||
# TODO use lib function to get headless mode
|
||||
headless = os.getenv("AYON_HEADLESS_MODE") == "1"
|
||||
self.addon_name: Optional[str] = addon_name
|
||||
self.addon_version: Optional[str] = addon_version
|
||||
headless = is_headless_mode_enabled()
|
||||
self.addon_name: str = addon_name
|
||||
self.addon_version: str = addon_version
|
||||
self.project_name: Optional[str] = project_name
|
||||
self.headless: bool = headless
|
||||
|
||||
|
|
|
|||
|
|
@ -72,13 +72,17 @@ def ensure_addons_are_process_context_ready(
|
|||
process_context: ProcessContext,
|
||||
addons_manager: Optional[AddonsManager] = None,
|
||||
exit_on_failure: bool = True,
|
||||
) -> Optional[Exception]:
|
||||
) -> bool:
|
||||
"""Ensure all enabled addons are ready to be used in the given context.
|
||||
|
||||
Call this method only in AYON launcher process and as first thing
|
||||
to avoid possible clashes with preparation. For example 'QApplication'
|
||||
should not be created.
|
||||
|
||||
Todos:
|
||||
Run all preparations and allow to "ignore" failed preparations.
|
||||
Right now single addon can block using certain actions.
|
||||
|
||||
Args:
|
||||
process_context (ProcessContext): The context in which the
|
||||
addons should be prepared.
|
||||
|
|
@ -88,14 +92,12 @@ def ensure_addons_are_process_context_ready(
|
|||
if an error occurs. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Optional[Exception]: The exception that occurred during the
|
||||
preparation, if any.
|
||||
bool: True if all addons are ready, False otherwise.
|
||||
|
||||
"""
|
||||
if addons_manager is None:
|
||||
addons_manager = AddonsManager()
|
||||
|
||||
exception = None
|
||||
message = None
|
||||
failed = False
|
||||
use_detail = False
|
||||
|
|
@ -112,13 +114,11 @@ def ensure_addons_are_process_context_ready(
|
|||
addon.ensure_is_process_ready(process_context)
|
||||
addon_failed = False
|
||||
except ProcessPreparationError as exc:
|
||||
exception = exc
|
||||
message = str(exc)
|
||||
print(f"Addon preparation failed: '{addon.name}'")
|
||||
print(message)
|
||||
|
||||
except BaseException as exc:
|
||||
exception = exc
|
||||
except BaseException:
|
||||
use_detail = True
|
||||
message = "An unexpected error occurred."
|
||||
formatted_traceback = "".join(traceback.format_exception(
|
||||
|
|
@ -140,7 +140,7 @@ def ensure_addons_are_process_context_ready(
|
|||
if not failed:
|
||||
if not process_context.headless:
|
||||
_start_tray()
|
||||
return None
|
||||
return True
|
||||
|
||||
detail = None
|
||||
if use_detail:
|
||||
|
|
@ -150,16 +150,21 @@ def ensure_addons_are_process_context_ready(
|
|||
detail = output_str
|
||||
|
||||
_handle_error(process_context, message, detail)
|
||||
if not exit_on_failure:
|
||||
return exception
|
||||
sys.exit(1)
|
||||
if exit_on_failure:
|
||||
sys.exit(1)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_addons_are_process_ready(
|
||||
addon_name: str,
|
||||
addon_version: str,
|
||||
project_name: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
*,
|
||||
addons_manager: Optional[AddonsManager] = None,
|
||||
exit_on_failure: bool = True,
|
||||
**kwargs,
|
||||
) -> Optional[Exception]:
|
||||
) -> bool:
|
||||
"""Ensure all enabled addons are ready to be used in the given context.
|
||||
|
||||
Call this method only in AYON launcher process and as first thing
|
||||
|
|
@ -167,6 +172,13 @@ def ensure_addons_are_process_ready(
|
|||
should not be created.
|
||||
|
||||
Args:
|
||||
addon_name (str): Addon name which triggered process.
|
||||
addon_version (str): Addon version which triggered process.
|
||||
project_name (Optional[str]): Project name. Can be filled in case
|
||||
process is triggered for specific project. Some addons can have
|
||||
different behavior based on project. Value is NOT autofilled.
|
||||
headless (Optional[bool]): Is process running in headless mode. Value
|
||||
is filled with value based on state set in AYON launcher.
|
||||
addons_manager (Optional[AddonsManager]): The addons
|
||||
manager to use. If not provided, a new one will be created.
|
||||
exit_on_failure (bool, optional): If True, the process will exit
|
||||
|
|
@ -174,11 +186,16 @@ def ensure_addons_are_process_ready(
|
|||
kwargs: The keyword arguments to pass to the ProcessContext.
|
||||
|
||||
Returns:
|
||||
Optional[Exception]: The exception that occurred during the
|
||||
preparation, if any.
|
||||
bool: True if all addons are ready, False otherwise.
|
||||
|
||||
"""
|
||||
context: ProcessContext = ProcessContext(**kwargs)
|
||||
context: ProcessContext = ProcessContext(
|
||||
addon_name,
|
||||
addon_version,
|
||||
project_name,
|
||||
headless,
|
||||
**kwargs
|
||||
)
|
||||
return ensure_addons_are_process_context_ready(
|
||||
context, addons_manager, exit_on_failure
|
||||
)
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ from .ayon_info import (
|
|||
is_in_ayon_launcher_process,
|
||||
is_running_from_build,
|
||||
is_using_ayon_console,
|
||||
is_headless_mode_enabled,
|
||||
is_staging_enabled,
|
||||
is_dev_mode_enabled,
|
||||
is_in_tests,
|
||||
|
|
@ -245,6 +246,7 @@ __all__ = [
|
|||
"is_in_ayon_launcher_process",
|
||||
"is_running_from_build",
|
||||
"is_using_ayon_console",
|
||||
"is_headless_mode_enabled",
|
||||
"is_staging_enabled",
|
||||
"is_dev_mode_enabled",
|
||||
"is_in_tests",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ def is_using_ayon_console():
|
|||
return "ayon_console" in executable_filename
|
||||
|
||||
|
||||
def is_headless_mode_enabled():
|
||||
return os.getenv("AYON_HEADLESS_MODE") == "1"
|
||||
|
||||
|
||||
def is_staging_enabled():
|
||||
return os.getenv("AYON_USE_STAGING") == "1"
|
||||
|
||||
|
|
|
|||
|
|
@ -460,6 +460,34 @@ class FormattingPart:
|
|||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_key_is_matched(key):
|
||||
"""Validate that opening has closing at correct place.
|
||||
Future-proof, only square brackets are currently used in keys.
|
||||
|
||||
Example:
|
||||
>>> is_matched("[]()()(((([])))")
|
||||
False
|
||||
>>> is_matched("[](){{{[]}}}")
|
||||
True
|
||||
|
||||
Returns:
|
||||
bool: Openings and closing are valid.
|
||||
|
||||
"""
|
||||
mapping = dict(zip("({[", ")}]"))
|
||||
opening = set(mapping.keys())
|
||||
closing = set(mapping.values())
|
||||
queue = []
|
||||
|
||||
for letter in key:
|
||||
if letter in opening:
|
||||
queue.append(mapping[letter])
|
||||
elif letter in closing:
|
||||
if not queue or letter != queue.pop():
|
||||
return False
|
||||
return not queue
|
||||
|
||||
def format(self, data, result):
|
||||
"""Format the formattings string.
|
||||
|
||||
|
|
@ -472,6 +500,12 @@ class FormattingPart:
|
|||
result.add_output(result.realy_used_values[key])
|
||||
return result
|
||||
|
||||
# ensure key is properly formed [({})] properly closed.
|
||||
if not self.validate_key_is_matched(key):
|
||||
result.add_missing_key(key)
|
||||
result.add_output(self.template)
|
||||
return result
|
||||
|
||||
# check if key expects subdictionary keys (e.g. project[name])
|
||||
existence_check = key
|
||||
key_padding = list(KEY_PADDING_PATTERN.findall(existence_check))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import ayon_api
|
||||
|
||||
from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.lib import filter_profiles, prepare_template_data
|
||||
|
||||
from .constants import DEFAULT_PRODUCT_TEMPLATE
|
||||
|
||||
|
|
@ -183,7 +182,10 @@ def get_product_name(
|
|||
fill_pairs[key] = value
|
||||
|
||||
try:
|
||||
return template.format(**prepare_template_data(fill_pairs))
|
||||
return StringTemplate.format_strict_template(
|
||||
template=template,
|
||||
data=prepare_template_data(fill_pairs)
|
||||
)
|
||||
except KeyError as exp:
|
||||
raise TemplateFillError(
|
||||
"Value for {} key is missing in template '{}'."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from copy import deepcopy
|
||||
|
|
@ -7,14 +7,11 @@ from copy import deepcopy
|
|||
import attr
|
||||
import ayon_api
|
||||
import clique
|
||||
|
||||
from ayon_core.pipeline import (
|
||||
get_current_project_name,
|
||||
get_representation_path,
|
||||
)
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.pipeline.publish import KnownPublishError
|
||||
from ayon_core.pipeline import get_current_project_name, get_representation_path
|
||||
from ayon_core.pipeline.create import get_product_name
|
||||
from ayon_core.pipeline.farm.patterning import match_aov_pattern
|
||||
from ayon_core.pipeline.publish import KnownPublishError
|
||||
|
||||
|
||||
@attr.s
|
||||
|
|
@ -250,6 +247,9 @@ def create_skeleton_instance(
|
|||
"colorspace": data.get("colorspace")
|
||||
}
|
||||
|
||||
if data.get("renderlayer"):
|
||||
instance_skeleton_data["renderlayer"] = data["renderlayer"]
|
||||
|
||||
# skip locking version if we are creating v01
|
||||
instance_version = data.get("version") # take this if exists
|
||||
if instance_version != 1:
|
||||
|
|
@ -464,7 +464,9 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
|
|||
Args:
|
||||
instance (pyblish.api.Instance): Original instance.
|
||||
skeleton (dict): Skeleton instance data.
|
||||
aov_filter (dict): AOV filter.
|
||||
skip_integration_repre_list (list): skip
|
||||
do_not_add_review (bool): Explicitly disable reviews
|
||||
|
||||
Returns:
|
||||
list of pyblish.api.Instance: Instances created from
|
||||
|
|
@ -515,6 +517,131 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
|
|||
)
|
||||
|
||||
|
||||
def _get_legacy_product_name_and_group(
|
||||
product_type,
|
||||
source_product_name,
|
||||
task_name,
|
||||
dynamic_data):
|
||||
"""Get product name with legacy logic.
|
||||
|
||||
This function holds legacy behaviour of creating product name
|
||||
that is deprecated. This wasn't using product name templates
|
||||
at all, only hardcoded values. It shouldn't be used anymore,
|
||||
but transition to templates need careful checking of the project
|
||||
and studio settings.
|
||||
|
||||
Deprecated:
|
||||
since 0.4.4
|
||||
|
||||
Args:
|
||||
product_type (str): Product type.
|
||||
source_product_name (str): Source product name.
|
||||
task_name (str): Task name.
|
||||
dynamic_data (dict): Dynamic data (camera, aov, ...)
|
||||
|
||||
Returns:
|
||||
tuple: product name and group name
|
||||
|
||||
"""
|
||||
warnings.warn("Using legacy product name for renders",
|
||||
DeprecationWarning)
|
||||
|
||||
if not source_product_name.startswith(product_type):
|
||||
resulting_group_name = '{}{}{}{}{}'.format(
|
||||
product_type,
|
||||
task_name[0].upper(), task_name[1:],
|
||||
source_product_name[0].upper(), source_product_name[1:])
|
||||
else:
|
||||
resulting_group_name = source_product_name
|
||||
|
||||
# create product name `<product type><Task><Product name>`
|
||||
if not source_product_name.startswith(product_type):
|
||||
resulting_group_name = '{}{}{}{}{}'.format(
|
||||
product_type,
|
||||
task_name[0].upper(), task_name[1:],
|
||||
source_product_name[0].upper(), source_product_name[1:])
|
||||
else:
|
||||
resulting_group_name = source_product_name
|
||||
|
||||
resulting_product_name = resulting_group_name
|
||||
camera = dynamic_data.get("camera")
|
||||
aov = dynamic_data.get("aov")
|
||||
if camera:
|
||||
if not aov:
|
||||
resulting_product_name = '{}_{}'.format(
|
||||
resulting_group_name, camera)
|
||||
elif not aov.startswith(camera):
|
||||
resulting_product_name = '{}_{}_{}'.format(
|
||||
resulting_group_name, camera, aov)
|
||||
else:
|
||||
resulting_product_name = "{}_{}".format(
|
||||
resulting_group_name, aov)
|
||||
else:
|
||||
if aov:
|
||||
resulting_product_name = '{}_{}'.format(
|
||||
resulting_group_name, aov)
|
||||
|
||||
return resulting_product_name, resulting_group_name
|
||||
|
||||
|
||||
def get_product_name_and_group_from_template(
|
||||
project_name,
|
||||
task_entity,
|
||||
product_type,
|
||||
variant,
|
||||
host_name,
|
||||
dynamic_data=None):
|
||||
"""Get product name and group name from template.
|
||||
|
||||
This will get product name and group name from template based on
|
||||
data provided. It is doing similar work as
|
||||
`func::_get_legacy_product_name_and_group` but using templates.
|
||||
|
||||
To get group name, template is called without any dynamic data, so
|
||||
(depending on the template itself) it should be product name without
|
||||
aov.
|
||||
|
||||
Todo:
|
||||
Maybe we should introduce templates for the groups themselves.
|
||||
|
||||
Args:
|
||||
task_entity (dict): Task entity.
|
||||
project_name (str): Project name.
|
||||
host_name (str): Host name.
|
||||
product_type (str): Product type.
|
||||
variant (str): Variant.
|
||||
dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...).
|
||||
|
||||
Returns:
|
||||
tuple: product name and group name.
|
||||
|
||||
"""
|
||||
# remove 'aov' from data used to format group. See todo comment above
|
||||
# for possible solution.
|
||||
_dynamic_data = deepcopy(dynamic_data) or {}
|
||||
_dynamic_data.pop("aov", None)
|
||||
resulting_group_name = get_product_name(
|
||||
project_name=project_name,
|
||||
task_name=task_entity["name"],
|
||||
task_type=task_entity["taskType"],
|
||||
host_name=host_name,
|
||||
product_type=product_type,
|
||||
dynamic_data=_dynamic_data,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
resulting_product_name = get_product_name(
|
||||
project_name=project_name,
|
||||
task_name=task_entity["name"],
|
||||
task_type=task_entity["taskType"],
|
||||
host_name=host_name,
|
||||
product_type=product_type,
|
||||
dynamic_data=dynamic_data,
|
||||
variant=variant,
|
||||
)
|
||||
return resulting_product_name, resulting_group_name
|
||||
|
||||
|
||||
def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
||||
skip_integration_repre_list, do_not_add_review):
|
||||
"""Create instance for each AOV found.
|
||||
|
|
@ -526,10 +653,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
instance (pyblish.api.Instance): Original instance.
|
||||
skeleton (dict): Skeleton data for instance (those needed) later
|
||||
by collector.
|
||||
additional_data (dict): ..
|
||||
additional_data (dict): ...
|
||||
skip_integration_repre_list (list): list of extensions that shouldn't
|
||||
be published
|
||||
do_not_addbe _review (bool): explicitly disable review
|
||||
do_not_add_review (bool): explicitly disable review
|
||||
|
||||
|
||||
Returns:
|
||||
|
|
@ -539,68 +666,70 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
ValueError:
|
||||
|
||||
"""
|
||||
# TODO: this needs to be taking the task from context or instance
|
||||
task = os.environ["AYON_TASK_NAME"]
|
||||
|
||||
anatomy = instance.context.data["anatomy"]
|
||||
s_product_name = skeleton["productName"]
|
||||
source_product_name = skeleton["productName"]
|
||||
cameras = instance.data.get("cameras", [])
|
||||
exp_files = instance.data["expectedFiles"]
|
||||
expected_files = instance.data["expectedFiles"]
|
||||
log = Logger.get_logger("farm_publishing")
|
||||
|
||||
instances = []
|
||||
# go through AOVs in expected files
|
||||
for aov, files in exp_files[0].items():
|
||||
cols, rem = clique.assemble(files)
|
||||
# we shouldn't have any reminders. And if we do, it should
|
||||
# be just one item for single frame renders.
|
||||
if not cols and rem:
|
||||
if len(rem) != 1:
|
||||
raise ValueError("Found multiple non related files "
|
||||
"to render, don't know what to do "
|
||||
"with them.")
|
||||
col = rem[0]
|
||||
ext = os.path.splitext(col)[1].lstrip(".")
|
||||
else:
|
||||
# but we really expect only one collection.
|
||||
# Nothing else make sense.
|
||||
if len(cols) != 1:
|
||||
raise ValueError("Only one image sequence type is expected.") # noqa: E501
|
||||
ext = cols[0].tail.lstrip(".")
|
||||
col = list(cols[0])
|
||||
for aov, files in expected_files[0].items():
|
||||
collected_files = _collect_expected_files_for_aov(files)
|
||||
|
||||
# create product name `<product type><Task><Product name>`
|
||||
# TODO refactor/remove me
|
||||
product_type = skeleton["productType"]
|
||||
if not s_product_name.startswith(product_type):
|
||||
group_name = '{}{}{}{}{}'.format(
|
||||
product_type,
|
||||
task[0].upper(), task[1:],
|
||||
s_product_name[0].upper(), s_product_name[1:])
|
||||
else:
|
||||
group_name = s_product_name
|
||||
expected_filepath = collected_files
|
||||
if isinstance(collected_files, (list, tuple)):
|
||||
expected_filepath = collected_files[0]
|
||||
|
||||
# if there are multiple cameras, we need to add camera name
|
||||
expected_filepath = col[0] if isinstance(col, (list, tuple)) else col
|
||||
cams = [cam for cam in cameras if cam in expected_filepath]
|
||||
if cams:
|
||||
for cam in cams:
|
||||
if not aov:
|
||||
product_name = '{}_{}'.format(group_name, cam)
|
||||
elif not aov.startswith(cam):
|
||||
product_name = '{}_{}_{}'.format(group_name, cam, aov)
|
||||
else:
|
||||
product_name = "{}_{}".format(group_name, aov)
|
||||
else:
|
||||
if aov:
|
||||
product_name = '{}_{}'.format(group_name, aov)
|
||||
else:
|
||||
product_name = '{}'.format(group_name)
|
||||
dynamic_data = {
|
||||
"aov": aov,
|
||||
"renderlayer": instance.data.get("renderlayer"),
|
||||
}
|
||||
|
||||
# find if camera is used in the file path
|
||||
# TODO: this must be changed to be more robust. Any coincidence
|
||||
# of camera name in the file path will be considered as
|
||||
# camera name. This is not correct.
|
||||
camera = [cam for cam in cameras if cam in expected_filepath]
|
||||
|
||||
# Is there just one camera matching?
|
||||
# TODO: this is not true, we can have multiple cameras in the scene
|
||||
# and we should be able to detect them all. Currently, we are
|
||||
# keeping the old behavior, taking the first one found.
|
||||
if camera:
|
||||
dynamic_data["camera"] = camera[0]
|
||||
|
||||
project_settings = instance.context.data.get("project_settings")
|
||||
|
||||
use_legacy_product_name = True
|
||||
try:
|
||||
use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501
|
||||
except KeyError:
|
||||
warnings.warn(
|
||||
("use_legacy_for_renders not found in project settings. "
|
||||
"Using legacy product name for renders. Please update "
|
||||
"your ayon-core version."), DeprecationWarning)
|
||||
use_legacy_product_name = True
|
||||
|
||||
if use_legacy_product_name:
|
||||
product_name, group_name = _get_legacy_product_name_and_group(
|
||||
product_type=skeleton["productType"],
|
||||
source_product_name=source_product_name,
|
||||
task_name=instance.data["task"],
|
||||
dynamic_data=dynamic_data)
|
||||
|
||||
if isinstance(col, (list, tuple)):
|
||||
staging = os.path.dirname(col[0])
|
||||
else:
|
||||
staging = os.path.dirname(col)
|
||||
product_name, group_name = get_product_name_and_group_from_template(
|
||||
task_entity=instance.data["taskEntity"],
|
||||
project_name=instance.context.data["projectName"],
|
||||
host_name=instance.context.data["hostName"],
|
||||
product_type=skeleton["productType"],
|
||||
variant=instance.data.get("variant", source_product_name),
|
||||
dynamic_data=dynamic_data
|
||||
)
|
||||
|
||||
staging = os.path.dirname(expected_filepath)
|
||||
|
||||
try:
|
||||
staging = remap_source(staging, anatomy)
|
||||
|
|
@ -611,10 +740,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
|
||||
app = os.environ.get("AYON_HOST_NAME", "")
|
||||
|
||||
if isinstance(col, list):
|
||||
render_file_name = os.path.basename(col[0])
|
||||
else:
|
||||
render_file_name = os.path.basename(col)
|
||||
render_file_name = os.path.basename(expected_filepath)
|
||||
|
||||
aov_patterns = aov_filter
|
||||
|
||||
preview = match_aov_pattern(app, aov_patterns, render_file_name)
|
||||
|
|
@ -622,9 +749,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
new_instance = deepcopy(skeleton)
|
||||
new_instance["productName"] = product_name
|
||||
new_instance["productGroup"] = group_name
|
||||
new_instance["aov"] = aov
|
||||
|
||||
# toggle preview on if multipart is on
|
||||
# Because we cant query the multipartExr data member of each AOV we'll
|
||||
# Because we can't query the multipartExr data member of each AOV we'll
|
||||
# need to have hardcoded rule of excluding any renders with
|
||||
# "cryptomatte" in the file name from being a multipart EXR. This issue
|
||||
# happens with Redshift that forces Cryptomatte renders to be separate
|
||||
|
|
@ -650,10 +778,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
new_instance["review"] = True
|
||||
|
||||
# create representation
|
||||
if isinstance(col, (list, tuple)):
|
||||
files = [os.path.basename(f) for f in col]
|
||||
else:
|
||||
files = os.path.basename(col)
|
||||
ext = os.path.splitext(render_file_name)[-1].lstrip(".")
|
||||
|
||||
# Copy render product "colorspace" data to representation.
|
||||
colorspace = ""
|
||||
|
|
@ -708,6 +833,35 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
return instances
|
||||
|
||||
|
||||
def _collect_expected_files_for_aov(files):
|
||||
"""Collect expected files.
|
||||
|
||||
Args:
|
||||
files (list): List of files.
|
||||
|
||||
Returns:
|
||||
list or str: Collection of files or single file.
|
||||
|
||||
Raises:
|
||||
ValueError: If there are multiple collections.
|
||||
|
||||
"""
|
||||
cols, rem = clique.assemble(files)
|
||||
# we shouldn't have any reminders. And if we do, it should
|
||||
# be just one item for single frame renders.
|
||||
if not cols and rem:
|
||||
if len(rem) != 1:
|
||||
raise ValueError("Found multiple non related files "
|
||||
"to render, don't know what to do "
|
||||
"with them.")
|
||||
return rem[0]
|
||||
# but we really expect only one collection.
|
||||
# Nothing else make sense.
|
||||
if len(cols) != 1:
|
||||
raise ValueError("Only one image sequence type is expected.") # noqa: E501
|
||||
return list(cols[0])
|
||||
|
||||
|
||||
def get_resources(project_name, version_entity, extension=None):
|
||||
"""Get the files from the specific version.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import pyblish.api
|
||||
from ayon_core.pipeline import Anatomy
|
||||
from typing import Tuple, List
|
||||
|
||||
|
||||
class TimeData:
|
||||
start: int
|
||||
end: int
|
||||
fps: float | int
|
||||
step: int
|
||||
handle_start: int
|
||||
handle_end: int
|
||||
|
||||
def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int):
|
||||
...
|
||||
...
|
||||
|
||||
def remap_source(source: str, anatomy: Anatomy): ...
|
||||
def extend_frames(folder_path: str, product_name: str, start: int, end: int) -> Tuple[int, int]: ...
|
||||
def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ...
|
||||
def get_transferable_representations(instance: pyblish.api.Instance) -> list: ...
|
||||
def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ...
|
||||
def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ...
|
||||
def attach_instances_to_product(attach_to: list, instances: list) -> list: ...
|
||||
|
|
@ -138,7 +138,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
folder_path_by_id = {}
|
||||
for instance in context:
|
||||
folder_entity = instance.data.get("folderEntity")
|
||||
# Skip if instnace does not have filled folder entity
|
||||
# Skip if instance does not have filled folder entity
|
||||
if not folder_entity:
|
||||
continue
|
||||
folder_id = folder_entity["id"]
|
||||
|
|
@ -385,8 +385,19 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
json.dumps(anatomy_data, indent=4)
|
||||
))
|
||||
|
||||
# make render layer available in anatomy data
|
||||
render_layer = instance.data.get("renderlayer")
|
||||
if render_layer:
|
||||
anatomy_data["renderlayer"] = render_layer
|
||||
|
||||
# make aov name available in anatomy data
|
||||
aov = instance.data.get("aov")
|
||||
if aov:
|
||||
anatomy_data["aov"] = aov
|
||||
|
||||
|
||||
def _fill_folder_data(self, instance, project_entity, anatomy_data):
|
||||
# QUESTION should we make sure that all folder data are poped if
|
||||
# QUESTION: should we make sure that all folder data are popped if
|
||||
# folder data cannot be found?
|
||||
# - 'folder', 'hierarchy', 'parent', 'folder'
|
||||
folder_entity = instance.data.get("folderEntity")
|
||||
|
|
@ -426,7 +437,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
})
|
||||
|
||||
def _fill_task_data(self, instance, task_types_by_name, anatomy_data):
|
||||
# QUESTION should we make sure that all task data are poped if task
|
||||
# QUESTION: should we make sure that all task data are popped if task
|
||||
# data cannot be resolved?
|
||||
# - 'task'
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ Multiples instances from your scene are set to publish into the same folder > pr
|
|||
|
||||
### How to repair?
|
||||
|
||||
Remove the offending instances or rename to have a unique name.
|
||||
Remove the offending instances or rename to have a unique name. Also, please
|
||||
check your product name templates to ensure that resolved names are
|
||||
sufficiently unique. You can find that settings:
|
||||
|
||||
ayon+settings://core/tools/creator/product_name_profiles
|
||||
</description>
|
||||
</error>
|
||||
</root>
|
||||
</root>
|
||||
|
|
|
|||
|
|
@ -744,6 +744,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
|
|||
if not is_udim:
|
||||
repre_context["frame"] = first_index_padded
|
||||
|
||||
# store renderlayer in context if it exists
|
||||
# to be later used for example by delivery templates
|
||||
if instance.data.get("renderlayer"):
|
||||
repre_context["renderlayer"] = instance.data["renderlayer"]
|
||||
|
||||
# Update the destination indexes and padding
|
||||
dst_collection = clique.assemble(dst_filepaths)[0][0]
|
||||
dst_collection.padding = destination_padding
|
||||
|
|
|
|||
|
|
@ -739,6 +739,31 @@ OverlayMessageWidget QWidget {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
/* Hinted Line Edit */
|
||||
HintedLineEditInput {
|
||||
border-radius: 0.2em;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid {color:border};
|
||||
}
|
||||
HintedLineEditInput:hover {
|
||||
border-color: {color:border-hover};
|
||||
}
|
||||
HintedLineEditInput:focus{
|
||||
border-color: {color:border-focus};
|
||||
}
|
||||
HintedLineEditInput:disabled {
|
||||
background: {color:bg-inputs-disabled};
|
||||
}
|
||||
HintedLineEditButton {
|
||||
border: none;
|
||||
border-radius: 0.2em;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
padding: 0px;
|
||||
qproperty-iconSize: 11px 11px;
|
||||
}
|
||||
|
||||
/* Password dialog*/
|
||||
#PasswordBtn {
|
||||
border: none;
|
||||
|
|
@ -969,17 +994,6 @@ PixmapButton:disabled {
|
|||
#PublishLogConsole {
|
||||
font-family: "Noto Sans Mono";
|
||||
}
|
||||
#VariantInputsWidget QLineEdit {
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
}
|
||||
#VariantInputsWidget QToolButton {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
width: 0.5em;
|
||||
}
|
||||
#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover {
|
||||
border-color: {color:publisher:success};
|
||||
}
|
||||
|
|
@ -1231,6 +1245,15 @@ ValidationArtistMessage QLabel {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
#PluginDetailsContent {
|
||||
background: {color:bg-inputs};
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
#PluginDetailsContent #PluginLabel {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
CreateNextPageOverlay {
|
||||
font-size: 32pt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class PublishReportMaker:
|
|||
"crashed_file_paths": crashed_file_paths,
|
||||
"id": uuid.uuid4().hex,
|
||||
"created_at": now.isoformat(),
|
||||
"report_version": "1.0.1",
|
||||
"report_version": "1.1.0",
|
||||
}
|
||||
|
||||
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
|
||||
|
|
@ -194,11 +194,23 @@ class PublishReportMaker:
|
|||
if hasattr(plugin, "label"):
|
||||
label = plugin.label
|
||||
|
||||
plugin_type = "instance" if plugin.__instanceEnabled__ else "context"
|
||||
# Get docstring
|
||||
# NOTE we do care only about docstring from the plugin so we can't
|
||||
# use 'inspect.getdoc' which also looks for docstring in parent
|
||||
# classes.
|
||||
docstring = getattr(plugin, "__doc__", None)
|
||||
if docstring:
|
||||
docstring = inspect.cleandoc(docstring)
|
||||
return {
|
||||
"id": plugin.id,
|
||||
"name": plugin.__name__,
|
||||
"label": label,
|
||||
"order": plugin.order,
|
||||
"filepath": inspect.getfile(plugin),
|
||||
"docstring": docstring,
|
||||
"plugin_type": plugin_type,
|
||||
"families": list(plugin.families),
|
||||
"targets": list(plugin.targets),
|
||||
"instances_data": [],
|
||||
"actions_data": [],
|
||||
|
|
|
|||
|
|
@ -13,8 +13,16 @@ class PluginItem:
|
|||
self.skipped = plugin_data["skipped"]
|
||||
self.passed = plugin_data["passed"]
|
||||
|
||||
# Introduced in report '1.1.0'
|
||||
self.docstring = plugin_data.get("docstring")
|
||||
self.filepath = plugin_data.get("filepath")
|
||||
self.plugin_type = plugin_data.get("plugin_type")
|
||||
self.families = plugin_data.get("families")
|
||||
|
||||
errored = False
|
||||
process_time = 0.0
|
||||
for instance_data in plugin_data["instances_data"]:
|
||||
process_time += instance_data["process_time"]
|
||||
for log_item in instance_data["logs"]:
|
||||
errored = log_item["type"] == "error"
|
||||
if errored:
|
||||
|
|
@ -22,6 +30,7 @@ class PluginItem:
|
|||
if errored:
|
||||
break
|
||||
|
||||
self.process_time = process_time
|
||||
self.errored = errored
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from qtpy import QtWidgets, QtCore, QtGui
|
|||
|
||||
from ayon_core.tools.utils import (
|
||||
NiceCheckbox,
|
||||
ElideLabel,
|
||||
SeparatorWidget,
|
||||
IconButton,
|
||||
paint_image_with_color,
|
||||
)
|
||||
|
|
@ -28,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
|
|||
IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
|
||||
|
||||
|
||||
class PluginLoadReportModel(QtGui.QStandardItemModel):
|
||||
def set_report(self, report):
|
||||
parent = self.invisibleRootItem()
|
||||
parent.removeRows(0, parent.rowCount())
|
||||
def get_pretty_milliseconds(value):
|
||||
if value < 1000:
|
||||
return f"{value:.3f}ms"
|
||||
value /= 1000
|
||||
if value < 60:
|
||||
return f"{value:.2f}s"
|
||||
seconds = int(value % 60)
|
||||
value /= 60
|
||||
if value < 60:
|
||||
return f"{value:.2f}m {seconds:.2f}s"
|
||||
minutes = int(value % 60)
|
||||
value /= 60
|
||||
return f"{value:.2f}h {minutes:.2f}m"
|
||||
|
||||
|
||||
class PluginLoadReportModel(QtGui.QStandardItemModel):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._traceback_by_filepath = {}
|
||||
self._items_by_filepath = {}
|
||||
self._is_active = True
|
||||
self._need_refresh = False
|
||||
|
||||
def set_active(self, is_active):
|
||||
if self._is_active is is_active:
|
||||
return
|
||||
self._is_active = is_active
|
||||
self._update_items()
|
||||
|
||||
def set_report(self, report):
|
||||
self._need_refresh = True
|
||||
if report is None:
|
||||
self._traceback_by_filepath.clear()
|
||||
self._update_items()
|
||||
return
|
||||
|
||||
filepaths = set(report.crashed_plugin_paths.keys())
|
||||
to_remove = set(self._traceback_by_filepath) - filepaths
|
||||
for filepath in filepaths:
|
||||
self._traceback_by_filepath[filepath] = (
|
||||
report.crashed_plugin_paths[filepath]
|
||||
)
|
||||
|
||||
for filepath in to_remove:
|
||||
self._traceback_by_filepath.pop(filepath)
|
||||
self._update_items()
|
||||
|
||||
def _update_items(self):
|
||||
if not self._is_active or not self._need_refresh:
|
||||
return
|
||||
parent = self.invisibleRootItem()
|
||||
if not self._traceback_by_filepath:
|
||||
parent.removeRows(0, parent.rowCount())
|
||||
return
|
||||
|
||||
new_items = []
|
||||
new_items_by_filepath = {}
|
||||
for filepath in report.crashed_plugin_paths.keys():
|
||||
to_remove = (
|
||||
set(self._items_by_filepath) - set(self._traceback_by_filepath)
|
||||
)
|
||||
for filepath in self._traceback_by_filepath:
|
||||
if filepath in self._items_by_filepath:
|
||||
continue
|
||||
item = QtGui.QStandardItem(filepath)
|
||||
new_items.append(item)
|
||||
new_items_by_filepath[filepath] = item
|
||||
self._items_by_filepath[filepath] = item
|
||||
|
||||
if not new_items:
|
||||
return
|
||||
if new_items:
|
||||
parent.appendRows(new_items)
|
||||
|
||||
parent.appendRows(new_items)
|
||||
for filepath, item in new_items_by_filepath.items():
|
||||
traceback_txt = report.crashed_plugin_paths[filepath]
|
||||
traceback_txt = self._traceback_by_filepath[filepath]
|
||||
detail_item = QtGui.QStandardItem()
|
||||
detail_item.setData(filepath, FILEPATH_ROLE)
|
||||
detail_item.setData(traceback_txt, TRACEBACK_ROLE)
|
||||
detail_item.setData(True, IS_DETAIL_ITEM_ROLE)
|
||||
item.appendRow(detail_item)
|
||||
|
||||
for filepath in to_remove:
|
||||
item = self._items_by_filepath.pop(filepath)
|
||||
parent.removeRow(item.row())
|
||||
|
||||
|
||||
class DetailWidget(QtWidgets.QTextEdit):
|
||||
def __init__(self, text, *args, **kwargs):
|
||||
|
|
@ -101,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
|
|||
self._model = model
|
||||
self._widgets_by_filepath = {}
|
||||
|
||||
def _on_expand(self, index):
|
||||
for row in range(self._model.rowCount(index)):
|
||||
child_index = self._model.index(row, index.column(), index)
|
||||
self._create_widget(child_index)
|
||||
def set_active(self, is_active):
|
||||
self._model.set_active(is_active)
|
||||
|
||||
def set_report(self, report):
|
||||
self._widgets_by_filepath = {}
|
||||
self._model.set_report(report)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
|
|
@ -114,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
|
|||
super().resizeEvent(event)
|
||||
self._update_widgets_size_hints()
|
||||
|
||||
def _on_expand(self, index):
|
||||
for row in range(self._model.rowCount(index)):
|
||||
child_index = self._model.index(row, index.column(), index)
|
||||
self._create_widget(child_index)
|
||||
|
||||
def _update_widgets_size_hints(self):
|
||||
for item in self._widgets_by_filepath.values():
|
||||
widget, index = item
|
||||
|
|
@ -142,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
|
|||
self._view.setIndexWidget(index, widget)
|
||||
self._widgets_by_filepath[filepath] = (widget, index)
|
||||
|
||||
def set_report(self, report):
|
||||
self._widgets_by_filepath = {}
|
||||
self._model.set_report(report)
|
||||
|
||||
|
||||
class ZoomPlainText(QtWidgets.QPlainTextEdit):
|
||||
min_point_size = 1.0
|
||||
|
|
@ -235,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget):
|
|||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(output_widget)
|
||||
|
||||
self._is_active = True
|
||||
self._need_refresh = False
|
||||
self._output_widget = output_widget
|
||||
self._report_item = None
|
||||
self._instance_filter = set()
|
||||
|
|
@ -243,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget):
|
|||
def clear(self):
|
||||
self._output_widget.setPlainText("")
|
||||
|
||||
def set_active(self, is_active):
|
||||
if self._is_active is is_active:
|
||||
return
|
||||
self._is_active = is_active
|
||||
self._update_logs()
|
||||
|
||||
def set_report(self, report):
|
||||
self._report_item = report
|
||||
self._plugin_filter = set()
|
||||
self._instance_filter = set()
|
||||
self._need_refresh = True
|
||||
self._update_logs()
|
||||
|
||||
def set_plugin_filter(self, plugin_filter):
|
||||
self._plugin_filter = plugin_filter
|
||||
self._need_refresh = True
|
||||
self._update_logs()
|
||||
|
||||
def set_instance_filter(self, instance_filter):
|
||||
self._instance_filter = instance_filter
|
||||
self._need_refresh = True
|
||||
self._update_logs()
|
||||
|
||||
def _update_logs(self):
|
||||
if not self._is_active or not self._need_refresh:
|
||||
return
|
||||
|
||||
if not self._report_item:
|
||||
self._output_widget.setPlainText("")
|
||||
return
|
||||
|
|
@ -300,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget):
|
|||
self._output_widget.setPlainText(text)
|
||||
|
||||
|
||||
class PluginDetailsWidget(QtWidgets.QWidget):
|
||||
def __init__(self, plugin_item, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
content_widget = QtWidgets.QFrame(self)
|
||||
content_widget.setObjectName("PluginDetailsContent")
|
||||
|
||||
plugin_label_widget = QtWidgets.QLabel(content_widget)
|
||||
plugin_label_widget.setObjectName("PluginLabel")
|
||||
|
||||
plugin_doc_widget = QtWidgets.QLabel(content_widget)
|
||||
plugin_doc_widget.setWordWrap(True)
|
||||
|
||||
form_separator = SeparatorWidget(parent=content_widget)
|
||||
|
||||
plugin_class_label = QtWidgets.QLabel("Class:")
|
||||
plugin_class_widget = QtWidgets.QLabel(content_widget)
|
||||
|
||||
plugin_order_label = QtWidgets.QLabel("Order:")
|
||||
plugin_order_widget = QtWidgets.QLabel(content_widget)
|
||||
|
||||
plugin_families_label = QtWidgets.QLabel("Families:")
|
||||
plugin_families_widget = QtWidgets.QLabel(content_widget)
|
||||
plugin_families_widget.setWordWrap(True)
|
||||
|
||||
plugin_path_label = QtWidgets.QLabel("File Path:")
|
||||
plugin_path_widget = ElideLabel(content_widget)
|
||||
plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft)
|
||||
|
||||
plugin_time_label = QtWidgets.QLabel("Time:")
|
||||
plugin_time_widget = QtWidgets.QLabel(content_widget)
|
||||
|
||||
# Set interaction flags
|
||||
for label_widget in (
|
||||
plugin_label_widget,
|
||||
plugin_doc_widget,
|
||||
plugin_class_widget,
|
||||
plugin_order_widget,
|
||||
plugin_families_widget,
|
||||
plugin_time_widget,
|
||||
):
|
||||
label_widget.setTextInteractionFlags(
|
||||
QtCore.Qt.TextBrowserInteraction
|
||||
)
|
||||
|
||||
# Change style of form labels
|
||||
for label_widget in (
|
||||
plugin_class_label,
|
||||
plugin_order_label,
|
||||
plugin_families_label,
|
||||
plugin_path_label,
|
||||
plugin_time_label,
|
||||
):
|
||||
label_widget.setObjectName("PluginFormLabel")
|
||||
|
||||
plugin_label = plugin_item.label or plugin_item.name
|
||||
if plugin_item.plugin_type:
|
||||
plugin_label += " ({})".format(
|
||||
plugin_item.plugin_type.capitalize()
|
||||
)
|
||||
|
||||
time_label = "Not started"
|
||||
if plugin_item.passed:
|
||||
time_label = get_pretty_milliseconds(plugin_item.process_time)
|
||||
elif plugin_item.skipped:
|
||||
time_label = "Skipped plugin"
|
||||
|
||||
families = "N/A"
|
||||
if plugin_item.families:
|
||||
families = ", ".join(plugin_item.families)
|
||||
|
||||
order = "N/A"
|
||||
if plugin_item.order is not None:
|
||||
order = str(plugin_item.order)
|
||||
|
||||
plugin_label_widget.setText(plugin_label)
|
||||
plugin_doc_widget.setText(plugin_item.docstring or "N/A")
|
||||
plugin_class_widget.setText(plugin_item.name or "N/A")
|
||||
plugin_order_widget.setText(order)
|
||||
plugin_families_widget.setText(families)
|
||||
plugin_path_widget.setText(plugin_item.filepath or "N/A")
|
||||
plugin_path_widget.setToolTip(plugin_item.filepath or None)
|
||||
plugin_time_widget.setText(time_label)
|
||||
|
||||
content_layout = QtWidgets.QGridLayout(content_widget)
|
||||
content_layout.setContentsMargins(8, 8, 8, 8)
|
||||
content_layout.setColumnStretch(0, 0)
|
||||
content_layout.setColumnStretch(1, 1)
|
||||
row = 0
|
||||
|
||||
content_layout.addWidget(plugin_label_widget, row, 0, 1, 2)
|
||||
row += 1
|
||||
|
||||
# Hide docstring if it is empty
|
||||
if plugin_item.docstring:
|
||||
content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2)
|
||||
row += 1
|
||||
else:
|
||||
plugin_doc_widget.setVisible(False)
|
||||
|
||||
content_layout.addWidget(form_separator, row, 0, 1, 2)
|
||||
row += 1
|
||||
|
||||
for label_widget, value_widget in (
|
||||
(plugin_class_label, plugin_class_widget),
|
||||
(plugin_order_label, plugin_order_widget),
|
||||
(plugin_families_label, plugin_families_widget),
|
||||
(plugin_path_label, plugin_path_widget),
|
||||
(plugin_time_label, plugin_time_widget),
|
||||
):
|
||||
content_layout.addWidget(label_widget, row, 0)
|
||||
content_layout.addWidget(value_widget, row, 1)
|
||||
row += 1
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(content_widget, 0)
|
||||
|
||||
|
||||
class PluginsDetailsWidget(QtWidgets.QWidget):
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
scroll_area = QtWidgets.QScrollArea(self)
|
||||
scroll_area.setWidgetResizable(True)
|
||||
|
||||
scroll_content_widget = QtWidgets.QWidget(scroll_area)
|
||||
|
||||
scroll_area.setWidget(scroll_content_widget)
|
||||
|
||||
empty_label = QtWidgets.QLabel(
|
||||
"<br/><br/>Select plugins to view more information...",
|
||||
scroll_content_widget
|
||||
)
|
||||
empty_label.setAlignment(QtCore.Qt.AlignCenter)
|
||||
|
||||
content_widget = QtWidgets.QWidget(scroll_content_widget)
|
||||
|
||||
content_layout = QtWidgets.QVBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
content_layout.setSpacing(10)
|
||||
|
||||
scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget)
|
||||
scroll_content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
scroll_content_layout.addWidget(empty_label, 0)
|
||||
scroll_content_layout.addWidget(content_widget, 0)
|
||||
scroll_content_layout.addStretch(1)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(scroll_area, 1)
|
||||
|
||||
content_widget.setVisible(False)
|
||||
|
||||
self._scroll_area = scroll_area
|
||||
self._empty_label = empty_label
|
||||
self._content_layout = content_layout
|
||||
self._content_widget = content_widget
|
||||
|
||||
self._widgets_by_plugin_id = {}
|
||||
self._stretch_item_index = 0
|
||||
|
||||
self._is_active = True
|
||||
self._need_refresh = False
|
||||
|
||||
self._report_item = None
|
||||
self._plugin_filter = set()
|
||||
self._plugin_ids = None
|
||||
|
||||
def set_active(self, is_active):
|
||||
if self._is_active is is_active:
|
||||
return
|
||||
self._is_active = is_active
|
||||
self._update_widgets()
|
||||
|
||||
def set_plugin_filter(self, plugin_filter):
|
||||
self._need_refresh = True
|
||||
self._plugin_filter = plugin_filter
|
||||
self._update_widgets()
|
||||
|
||||
def set_report(self, report):
|
||||
self._plugin_ids = None
|
||||
self._plugin_filter = set()
|
||||
self._need_refresh = True
|
||||
self._report_item = report
|
||||
self._update_widgets()
|
||||
|
||||
def _get_plugin_ids(self):
|
||||
if self._plugin_ids is not None:
|
||||
return self._plugin_ids
|
||||
|
||||
# Clear layout and clear widgets
|
||||
while self._content_layout.count():
|
||||
item = self._content_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.setVisible(False)
|
||||
widget.deleteLater()
|
||||
|
||||
self._widgets_by_plugin_id.clear()
|
||||
|
||||
plugin_ids = []
|
||||
if self._report_item is not None:
|
||||
plugin_ids = list(self._report_item.plugins_id_order)
|
||||
self._plugin_ids = plugin_ids
|
||||
return plugin_ids
|
||||
|
||||
def _update_widgets(self):
|
||||
if not self._is_active or not self._need_refresh:
|
||||
return
|
||||
|
||||
self._need_refresh = False
|
||||
|
||||
# Hide content widget before updating
|
||||
# - add widgets to layout can happen without recalculating
|
||||
# the layout and widget size hints
|
||||
self._content_widget.setVisible(False)
|
||||
|
||||
any_visible = False
|
||||
for plugin_id in self._get_plugin_ids():
|
||||
widget = self._widgets_by_plugin_id.get(plugin_id)
|
||||
if widget is None:
|
||||
plugin_item = self._report_item.plugins_items_by_id[plugin_id]
|
||||
widget = PluginDetailsWidget(plugin_item, self._content_widget)
|
||||
self._widgets_by_plugin_id[plugin_id] = widget
|
||||
self._content_layout.addWidget(widget, 0)
|
||||
|
||||
is_visible = plugin_id in self._plugin_filter
|
||||
widget.setVisible(is_visible)
|
||||
if is_visible:
|
||||
any_visible = True
|
||||
|
||||
self._content_widget.setVisible(any_visible)
|
||||
self._empty_label.setVisible(not any_visible)
|
||||
|
||||
|
||||
class DeselectableTreeView(QtWidgets.QTreeView):
|
||||
"""A tree view that deselects on clicking on an empty area in the view"""
|
||||
|
||||
|
|
@ -446,9 +757,16 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
|
||||
logs_text_widget = DetailsWidget(details_tab_widget)
|
||||
plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
|
||||
plugins_details_widget = PluginsDetailsWidget(details_tab_widget)
|
||||
|
||||
plugin_load_report_widget.set_active(False)
|
||||
plugins_details_widget.set_active(False)
|
||||
|
||||
details_tab_widget.addTab(logs_text_widget, "Logs")
|
||||
details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
|
||||
details_tab_widget.addTab(plugins_details_widget, "Plugins Details")
|
||||
details_tab_widget.addTab(
|
||||
plugin_load_report_widget, "Crashed plugins"
|
||||
)
|
||||
|
||||
middle_widget = QtWidgets.QWidget(self)
|
||||
middle_layout = QtWidgets.QGridLayout(middle_widget)
|
||||
|
|
@ -465,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
layout.addWidget(middle_widget, 0)
|
||||
layout.addWidget(details_widget, 1)
|
||||
|
||||
details_tab_widget.currentChanged.connect(self._on_tab_change)
|
||||
instances_view.selectionModel().selectionChanged.connect(
|
||||
self._on_instance_change
|
||||
)
|
||||
|
|
@ -483,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
details_popup_btn.clicked.connect(self._on_details_popup)
|
||||
details_popup.closed.connect(self._on_popup_close)
|
||||
|
||||
self._current_tab_idx = 0
|
||||
self._ignore_selection_changes = False
|
||||
self._report_item = None
|
||||
self._logs_text_widget = logs_text_widget
|
||||
self._plugin_load_report_widget = plugin_load_report_widget
|
||||
self._plugins_details_widget = plugins_details_widget
|
||||
|
||||
self._removed_instances_check = removed_instances_check
|
||||
self._instances_view = instances_view
|
||||
|
|
@ -523,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
else:
|
||||
self._plugins_view.expand(index)
|
||||
|
||||
def set_active(self, active):
|
||||
for idx in range(self._details_tab_widget.count()):
|
||||
widget = self._details_tab_widget.widget(idx)
|
||||
widget.set_active(active and idx == self._current_tab_idx)
|
||||
|
||||
if not active:
|
||||
self.close_details_popup()
|
||||
|
||||
def set_report_data(self, report_data):
|
||||
report = PublishReport(report_data)
|
||||
self.set_report(report)
|
||||
|
|
@ -536,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
self._plugins_model.set_report(report)
|
||||
self._logs_text_widget.set_report(report)
|
||||
self._plugin_load_report_widget.set_report(report)
|
||||
self._plugins_details_widget.set_report(report)
|
||||
|
||||
self._ignore_selection_changes = False
|
||||
|
||||
self._instances_view.expandAll()
|
||||
self._plugins_view.expandAll()
|
||||
|
||||
def _on_tab_change(self, new_idx):
|
||||
if self._current_tab_idx == new_idx:
|
||||
return
|
||||
old_widget = self._details_tab_widget.widget(self._current_tab_idx)
|
||||
new_widget = self._details_tab_widget.widget(new_idx)
|
||||
self._current_tab_idx = new_idx
|
||||
old_widget.set_active(False)
|
||||
new_widget.set_active(True)
|
||||
|
||||
def _on_instance_change(self, *_args):
|
||||
if self._ignore_selection_changes:
|
||||
return
|
||||
|
|
@ -563,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
|
|||
plugin_ids.add(index.data(ITEM_ID_ROLE))
|
||||
|
||||
self._logs_text_widget.set_plugin_filter(plugin_ids)
|
||||
self._plugins_details_widget.set_plugin_filter(plugin_ids)
|
||||
|
||||
def _on_skipped_plugin_check(self):
|
||||
self._plugins_proxy.set_ignore_skipped(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from ayon_core.tools.publisher.constants import (
|
|||
INPUTS_LAYOUT_HSPACING,
|
||||
INPUTS_LAYOUT_VSPACING,
|
||||
)
|
||||
from ayon_core.tools.utils import HintedLineEdit
|
||||
|
||||
from .thumbnail_widget import ThumbnailWidget
|
||||
from .widgets import (
|
||||
|
|
@ -28,8 +29,6 @@ from .widgets import (
|
|||
from .create_context_widgets import CreateContextWidget
|
||||
from .precreate_widget import PreCreateWidget
|
||||
|
||||
SEPARATORS = ("---separator---", "---")
|
||||
|
||||
|
||||
class ResizeControlWidget(QtWidgets.QWidget):
|
||||
resized = QtCore.Signal()
|
||||
|
|
@ -168,25 +167,9 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
|
||||
product_variant_widget = QtWidgets.QWidget(creator_basics_widget)
|
||||
# Variant and product input
|
||||
variant_widget = ResizeControlWidget(product_variant_widget)
|
||||
variant_widget.setObjectName("VariantInputsWidget")
|
||||
|
||||
variant_input = QtWidgets.QLineEdit(variant_widget)
|
||||
variant_input.setObjectName("VariantInput")
|
||||
variant_input.setToolTip(VARIANT_TOOLTIP)
|
||||
|
||||
variant_hints_btn = QtWidgets.QToolButton(variant_widget)
|
||||
variant_hints_btn.setArrowType(QtCore.Qt.DownArrow)
|
||||
variant_hints_btn.setIconSize(QtCore.QSize(12, 12))
|
||||
|
||||
variant_hints_menu = QtWidgets.QMenu(variant_widget)
|
||||
variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu)
|
||||
|
||||
variant_layout = QtWidgets.QHBoxLayout(variant_widget)
|
||||
variant_layout.setContentsMargins(0, 0, 0, 0)
|
||||
variant_layout.setSpacing(0)
|
||||
variant_layout.addWidget(variant_input, 1)
|
||||
variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter)
|
||||
variant_widget = HintedLineEdit(parent=product_variant_widget)
|
||||
variant_widget.set_text_widget_object_name("VariantInput")
|
||||
variant_widget.setToolTip(VARIANT_TOOLTIP)
|
||||
|
||||
product_name_input = QtWidgets.QLineEdit(product_variant_widget)
|
||||
product_name_input.setEnabled(False)
|
||||
|
|
@ -262,15 +245,12 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
prereq_timer.timeout.connect(self._invalidate_prereq)
|
||||
|
||||
create_btn.clicked.connect(self._on_create)
|
||||
variant_widget.resized.connect(self._on_variant_widget_resize)
|
||||
creator_basics_widget.resized.connect(self._on_creator_basics_resize)
|
||||
variant_input.returnPressed.connect(self._on_create)
|
||||
variant_input.textChanged.connect(self._on_variant_change)
|
||||
variant_widget.returnPressed.connect(self._on_create)
|
||||
variant_widget.textChanged.connect(self._on_variant_change)
|
||||
creators_view.selectionModel().currentChanged.connect(
|
||||
self._on_creator_item_change
|
||||
)
|
||||
variant_hints_btn.clicked.connect(self._on_variant_btn_click)
|
||||
variant_hints_menu.triggered.connect(self._on_variant_action)
|
||||
context_widget.folder_changed.connect(self._on_folder_change)
|
||||
context_widget.task_changed.connect(self._on_task_change)
|
||||
thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create)
|
||||
|
|
@ -291,10 +271,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
|
||||
self.product_name_input = product_name_input
|
||||
|
||||
self.variant_input = variant_input
|
||||
self.variant_hints_btn = variant_hints_btn
|
||||
self.variant_hints_menu = variant_hints_menu
|
||||
self.variant_hints_group = variant_hints_group
|
||||
self._variant_widget = variant_widget
|
||||
|
||||
self._creators_model = creators_model
|
||||
self._creators_sort_model = creators_sort_model
|
||||
|
|
@ -314,6 +291,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
self._last_current_context_folder_path = None
|
||||
self._last_current_context_task = None
|
||||
self._use_current_context = True
|
||||
self._current_creator_variant_hints = []
|
||||
|
||||
def get_current_folder_path(self):
|
||||
return self._controller.get_current_folder_path()
|
||||
|
|
@ -438,8 +416,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
|
||||
self._create_btn.setEnabled(prereq_available)
|
||||
|
||||
self.variant_input.setEnabled(prereq_available)
|
||||
self.variant_hints_btn.setEnabled(prereq_available)
|
||||
self._variant_widget.setEnabled(prereq_available)
|
||||
|
||||
tooltip = ""
|
||||
if creator_btn_tooltips:
|
||||
|
|
@ -611,35 +588,15 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
if not default_variant:
|
||||
default_variant = default_variants[0]
|
||||
|
||||
for action in tuple(self.variant_hints_menu.actions()):
|
||||
self.variant_hints_menu.removeAction(action)
|
||||
action.deleteLater()
|
||||
|
||||
for variant in default_variants:
|
||||
if variant in SEPARATORS:
|
||||
self.variant_hints_menu.addSeparator()
|
||||
elif variant:
|
||||
self.variant_hints_menu.addAction(variant)
|
||||
self._current_creator_variant_hints = list(default_variants)
|
||||
self._variant_widget.set_options(default_variants)
|
||||
|
||||
variant_text = default_variant or DEFAULT_VARIANT_VALUE
|
||||
# Make sure product name is updated to new plugin
|
||||
if variant_text == self.variant_input.text():
|
||||
if variant_text == self._variant_widget.text():
|
||||
self._on_variant_change()
|
||||
else:
|
||||
self.variant_input.setText(variant_text)
|
||||
|
||||
def _on_variant_widget_resize(self):
|
||||
self.variant_hints_btn.setFixedHeight(self.variant_input.height())
|
||||
|
||||
def _on_variant_btn_click(self):
|
||||
pos = self.variant_hints_btn.rect().bottomLeft()
|
||||
point = self.variant_hints_btn.mapToGlobal(pos)
|
||||
self.variant_hints_menu.popup(point)
|
||||
|
||||
def _on_variant_action(self, action):
|
||||
value = action.text()
|
||||
if self.variant_input.text() != value:
|
||||
self.variant_input.setText(value)
|
||||
self._variant_widget.setText(variant_text)
|
||||
|
||||
def _on_variant_change(self, variant_value=None):
|
||||
if not self._prereq_available:
|
||||
|
|
@ -652,7 +609,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
return
|
||||
|
||||
if variant_value is None:
|
||||
variant_value = self.variant_input.text()
|
||||
variant_value = self._variant_widget.text()
|
||||
|
||||
if not self._compiled_name_pattern.match(variant_value):
|
||||
self._create_btn.setEnabled(False)
|
||||
|
|
@ -707,20 +664,12 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
if _result:
|
||||
variant_hints |= set(_result.groups())
|
||||
|
||||
# Remove previous hints from menu
|
||||
for action in tuple(self.variant_hints_group.actions()):
|
||||
self.variant_hints_group.removeAction(action)
|
||||
self.variant_hints_menu.removeAction(action)
|
||||
action.deleteLater()
|
||||
|
||||
# Add separator if there are hints and menu already has actions
|
||||
if variant_hints and self.variant_hints_menu.actions():
|
||||
self.variant_hints_menu.addSeparator()
|
||||
|
||||
options = list(self._current_creator_variant_hints)
|
||||
if options:
|
||||
options.append("---")
|
||||
options.extend(variant_hints)
|
||||
# Add hints to actions
|
||||
for variant_hint in variant_hints:
|
||||
action = self.variant_hints_menu.addAction(variant_hint)
|
||||
self.variant_hints_group.addAction(action)
|
||||
self._variant_widget.set_options(options)
|
||||
|
||||
# Indicate product existence
|
||||
if not variant_value:
|
||||
|
|
@ -741,10 +690,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
self._create_btn.setEnabled(variant_is_valid)
|
||||
|
||||
def _set_variant_state_property(self, state):
|
||||
current_value = self.variant_input.property("state")
|
||||
if current_value != state:
|
||||
self.variant_input.setProperty("state", state)
|
||||
self.variant_input.style().polish(self.variant_input)
|
||||
self._variant_widget.set_text_widget_property("state", state)
|
||||
|
||||
def _on_first_show(self):
|
||||
width = self.width()
|
||||
|
|
@ -776,7 +722,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
index = indexes[0]
|
||||
creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE)
|
||||
product_type = index.data(PRODUCT_TYPE_ROLE)
|
||||
variant = self.variant_input.text()
|
||||
variant = self._variant_widget.text()
|
||||
# Care about product name only if context change is enabled
|
||||
product_name = None
|
||||
folder_path = None
|
||||
|
|
@ -810,7 +756,7 @@ class CreateWidget(QtWidgets.QWidget):
|
|||
|
||||
if success:
|
||||
self._set_creator(self._selected_creator)
|
||||
self.variant_input.setText(variant)
|
||||
self._variant_widget.setText(variant)
|
||||
self._controller.emit_card_message("Creation finished...")
|
||||
self._last_thumbnail_path = None
|
||||
self._thumbnail_widget.set_current_thumbnails()
|
||||
|
|
|
|||
|
|
@ -687,13 +687,14 @@ class PublisherWindow(QtWidgets.QDialog):
|
|||
|
||||
def _on_tab_change(self, old_tab, new_tab):
|
||||
if old_tab == "details":
|
||||
self._publish_details_widget.close_details_popup()
|
||||
self._publish_details_widget.set_active(False)
|
||||
|
||||
if new_tab == "details":
|
||||
self._content_stacked_layout.setCurrentWidget(
|
||||
self._publish_details_widget
|
||||
)
|
||||
self._update_publish_details_widget()
|
||||
self._publish_details_widget.set_active(True)
|
||||
|
||||
elif new_tab == "report":
|
||||
self._content_stacked_layout.setCurrentWidget(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from .widgets import (
|
|||
ComboBox,
|
||||
CustomTextComboBox,
|
||||
PlaceholderLineEdit,
|
||||
ElideLabel,
|
||||
HintedLineEdit,
|
||||
ExpandingTextEdit,
|
||||
BaseClickableFrame,
|
||||
ClickableFrame,
|
||||
|
|
@ -88,6 +90,8 @@ __all__ = (
|
|||
"ComboBox",
|
||||
"CustomTextComboBox",
|
||||
"PlaceholderLineEdit",
|
||||
"ElideLabel",
|
||||
"HintedLineEdit",
|
||||
"ExpandingTextEdit",
|
||||
"BaseClickableFrame",
|
||||
"ClickableFrame",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
from typing import Optional, List, Set, Any
|
||||
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
import qargparse
|
||||
|
|
@ -11,7 +12,7 @@ from ayon_core.style import (
|
|||
)
|
||||
from ayon_core.lib.attribute_definitions import AbstractAttrDef
|
||||
|
||||
from .lib import get_qta_icon_by_name_and_color
|
||||
from .lib import get_qta_icon_by_name_and_color, set_style_property
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -104,6 +105,253 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
|
|||
self.setPalette(filter_palette)
|
||||
|
||||
|
||||
class ElideLabel(QtWidgets.QLabel):
|
||||
"""Label which elide text.
|
||||
|
||||
By default, elide happens on right side. Can be changed with
|
||||
'set_elide_mode' method.
|
||||
|
||||
It is not possible to use other features of QLabel like word wrap or
|
||||
interactive text. This is a simple label which elide text.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Preferred
|
||||
)
|
||||
# Store text set during init
|
||||
self._text = self.text()
|
||||
# Define initial elide mode
|
||||
self._elide_mode = QtCore.Qt.ElideRight
|
||||
# Make sure that text of QLabel is empty
|
||||
super().setText("")
|
||||
|
||||
def setText(self, text):
|
||||
# Update private text attribute and force update
|
||||
self._text = text
|
||||
self.update()
|
||||
|
||||
def setWordWrap(self, word_wrap):
|
||||
# Word wrap is not supported in 'ElideLabel'
|
||||
if word_wrap:
|
||||
raise ValueError("Word wrap is not supported in 'ElideLabel'.")
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.create_context_menu(event.pos())
|
||||
if menu is None:
|
||||
event.ignore()
|
||||
return
|
||||
event.accept()
|
||||
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
|
||||
menu.popup(event.globalPos())
|
||||
|
||||
def create_context_menu(self, pos):
|
||||
if not self._text:
|
||||
return None
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
# Copy text action
|
||||
copy_action = menu.addAction("Copy")
|
||||
copy_action.setObjectName("edit-copy")
|
||||
icon = QtGui.QIcon.fromTheme("edit-copy")
|
||||
if not icon.isNull():
|
||||
copy_action.setIcon(icon)
|
||||
|
||||
copy_action.triggered.connect(self._on_copy_text)
|
||||
return menu
|
||||
|
||||
def set_set(self, text):
|
||||
self.setText(text)
|
||||
|
||||
def set_elide_mode(self, elide_mode):
|
||||
"""Change elide type.
|
||||
|
||||
Args:
|
||||
elide_mode: Possible elide type. Available in 'QtCore.Qt'
|
||||
'ElideLeft', 'ElideRight' and 'ElideMiddle'.
|
||||
|
||||
"""
|
||||
if elide_mode == QtCore.Qt.ElideNone:
|
||||
raise ValueError(
|
||||
"Invalid elide type. 'ElideNone' is not supported."
|
||||
)
|
||||
|
||||
if elide_mode not in (
|
||||
QtCore.Qt.ElideLeft,
|
||||
QtCore.Qt.ElideRight,
|
||||
QtCore.Qt.ElideMiddle,
|
||||
):
|
||||
raise ValueError(f"Unknown value '{elide_mode}'")
|
||||
self._elide_mode = elide_mode
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
|
||||
painter = QtGui.QPainter(self)
|
||||
fm = painter.fontMetrics()
|
||||
elided_line = fm.elidedText(
|
||||
self._text, self._elide_mode, self.width()
|
||||
)
|
||||
painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line)
|
||||
|
||||
def _on_copy_text(self):
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
clipboard.setText(self._text)
|
||||
|
||||
|
||||
class _LocalCache:
|
||||
down_arrow_icon = None
|
||||
|
||||
|
||||
def get_down_arrow_icon() -> QtGui.QIcon:
|
||||
if _LocalCache.down_arrow_icon is not None:
|
||||
return _LocalCache.down_arrow_icon
|
||||
|
||||
normal_pixmap = QtGui.QPixmap(
|
||||
get_style_image_path("down_arrow")
|
||||
)
|
||||
on_pixmap = QtGui.QPixmap(
|
||||
get_style_image_path("down_arrow_on")
|
||||
)
|
||||
disabled_pixmap = QtGui.QPixmap(
|
||||
get_style_image_path("down_arrow_disabled")
|
||||
)
|
||||
icon = QtGui.QIcon(normal_pixmap)
|
||||
icon.addPixmap(on_pixmap, QtGui.QIcon.Active)
|
||||
icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled)
|
||||
_LocalCache.down_arrow_icon = icon
|
||||
return icon
|
||||
|
||||
|
||||
# These are placeholders for adding style
|
||||
class HintedLineEditInput(PlaceholderLineEdit):
|
||||
pass
|
||||
|
||||
|
||||
class HintedLineEditButton(QtWidgets.QPushButton):
|
||||
pass
|
||||
|
||||
|
||||
class HintedLineEdit(QtWidgets.QWidget):
|
||||
SEPARATORS: Set[str] = {"---", "---separator---"}
|
||||
returnPressed = QtCore.Signal()
|
||||
textChanged = QtCore.Signal(str)
|
||||
textEdited = QtCore.Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
options: Optional[List[str]] = None,
|
||||
parent: Optional[QtWidgets.QWidget] = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
text_input = HintedLineEditInput(self)
|
||||
options_button = HintedLineEditButton(self)
|
||||
options_button.setIcon(get_down_arrow_icon())
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.addWidget(text_input, 1)
|
||||
main_layout.addWidget(options_button, 0)
|
||||
|
||||
# Expand line edit and button vertically so they have same height
|
||||
for widget in (text_input, options_button):
|
||||
w_size_policy = widget.sizePolicy()
|
||||
w_size_policy.setVerticalPolicy(
|
||||
QtWidgets.QSizePolicy.MinimumExpanding)
|
||||
widget.setSizePolicy(w_size_policy)
|
||||
|
||||
# Set size hint of this frame to fixed so size hint height is
|
||||
# used as fixed height
|
||||
size_policy = self.sizePolicy()
|
||||
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed)
|
||||
self.setSizePolicy(size_policy)
|
||||
|
||||
text_input.returnPressed.connect(self.returnPressed)
|
||||
text_input.textChanged.connect(self.textChanged)
|
||||
text_input.textEdited.connect(self.textEdited)
|
||||
options_button.clicked.connect(self._on_options_button_clicked)
|
||||
|
||||
self._text_input = text_input
|
||||
self._options_button = options_button
|
||||
self._options = None
|
||||
|
||||
# Set default state
|
||||
self.set_options(options)
|
||||
|
||||
def text(self) -> str:
|
||||
return self._text_input.text()
|
||||
|
||||
def setText(self, text: str):
|
||||
self._text_input.setText(text)
|
||||
|
||||
def setPlaceholderText(self, text: str):
|
||||
self._text_input.setPlaceholderText(text)
|
||||
|
||||
def placeholderText(self) -> str:
|
||||
return self._text_input.placeholderText()
|
||||
|
||||
def setReadOnly(self, state: bool):
|
||||
self._text_input.setReadOnly(state)
|
||||
|
||||
def setIcon(self, icon: QtGui.QIcon):
|
||||
self._options_button.setIcon(icon)
|
||||
|
||||
def setToolTip(self, text: str):
|
||||
self._text_input.setToolTip(text)
|
||||
|
||||
def set_button_tool_tip(self, text: str):
|
||||
self._options_button.setToolTip(text)
|
||||
|
||||
def set_options(self, options: Optional[List[str]] = None):
|
||||
self._options = options
|
||||
self._options_button.setEnabled(bool(options))
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
hint = super().sizeHint()
|
||||
tsz = self._text_input.sizeHint()
|
||||
bsz = self._options_button.sizeHint()
|
||||
hint.setHeight(max(tsz.height(), bsz.height()))
|
||||
return hint
|
||||
|
||||
# Adds ability to change style of the widgets
|
||||
# - because style change of the 'HintedLineEdit' may not propagate
|
||||
# correctly 'HintedLineEditInput' and 'HintedLineEditButton'
|
||||
def set_text_widget_object_name(self, name: str):
|
||||
self._text_input.setObjectName(name)
|
||||
|
||||
def set_text_widget_property(self, name: str, value: Any):
|
||||
set_style_property(self._text_input, name, value)
|
||||
|
||||
def set_button_widget_object_name(self, name: str):
|
||||
self._text_input.setObjectName(name)
|
||||
|
||||
def set_button_widget_property(self, name: str, value: Any):
|
||||
set_style_property(self._options_button, name, value)
|
||||
|
||||
def _on_options_button_clicked(self):
|
||||
if not self._options:
|
||||
return
|
||||
|
||||
menu = QtWidgets.QMenu(self)
|
||||
menu.triggered.connect(self._on_option_action)
|
||||
for option in self._options:
|
||||
if option in self.SEPARATORS:
|
||||
menu.addSeparator()
|
||||
else:
|
||||
menu.addAction(option)
|
||||
|
||||
rect = self._options_button.rect()
|
||||
pos = self._options_button.mapToGlobal(rect.bottomLeft())
|
||||
menu.exec_(pos)
|
||||
|
||||
def _on_option_action(self, action):
|
||||
self.setText(action.text())
|
||||
|
||||
|
||||
class ExpandingTextEdit(QtWidgets.QTextEdit):
|
||||
"""QTextEdit which does not have sroll area but expands height."""
|
||||
|
||||
|
|
@ -206,6 +454,8 @@ class ExpandBtnLabel(QtWidgets.QLabel):
|
|||
"""Label showing expand icon meant for ExpandBtn."""
|
||||
state_changed = QtCore.Signal()
|
||||
|
||||
branch_closed_path = get_style_image_path("branch_closed")
|
||||
branch_open_path = get_style_image_path("branch_open")
|
||||
|
||||
def __init__(self, parent):
|
||||
super(ExpandBtnLabel, self).__init__(parent)
|
||||
|
|
@ -216,14 +466,10 @@ class ExpandBtnLabel(QtWidgets.QLabel):
|
|||
self._collapsed = True
|
||||
|
||||
def _create_collapsed_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("branch_closed")
|
||||
)
|
||||
return QtGui.QPixmap(self.branch_closed_path)
|
||||
|
||||
def _create_expanded_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("branch_open")
|
||||
)
|
||||
return QtGui.QPixmap(self.branch_open_path)
|
||||
|
||||
@property
|
||||
def collapsed(self):
|
||||
|
|
@ -291,15 +537,14 @@ class ExpandBtn(ClickableFrame):
|
|||
|
||||
|
||||
class ClassicExpandBtnLabel(ExpandBtnLabel):
|
||||
right_arrow_path = get_style_image_path("right_arrow")
|
||||
down_arrow_path = get_style_image_path("down_arrow")
|
||||
|
||||
def _create_collapsed_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("right_arrow")
|
||||
)
|
||||
return QtGui.QPixmap(self.right_arrow_path)
|
||||
|
||||
def _create_expanded_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("down_arrow")
|
||||
)
|
||||
return QtGui.QPixmap(self.down_arrow_path)
|
||||
|
||||
|
||||
class ClassicExpandBtn(ExpandBtn):
|
||||
|
|
|
|||
|
|
@ -562,12 +562,12 @@ class ExtractBurninDef(BaseSettingsModel):
|
|||
_isGroup = True
|
||||
_layout = "expanded"
|
||||
name: str = SettingsField("")
|
||||
TOP_LEFT: str = SettingsField("", topic="Top Left")
|
||||
TOP_CENTERED: str = SettingsField("", topic="Top Centered")
|
||||
TOP_RIGHT: str = SettingsField("", topic="Top Right")
|
||||
BOTTOM_LEFT: str = SettingsField("", topic="Bottom Left")
|
||||
BOTTOM_CENTERED: str = SettingsField("", topic="Bottom Centered")
|
||||
BOTTOM_RIGHT: str = SettingsField("", topic="Bottom Right")
|
||||
TOP_LEFT: str = SettingsField("", title="Top Left")
|
||||
TOP_CENTERED: str = SettingsField("", title="Top Centered")
|
||||
TOP_RIGHT: str = SettingsField("", title="Top Right")
|
||||
BOTTOM_LEFT: str = SettingsField("", title="Bottom Left")
|
||||
BOTTOM_CENTERED: str = SettingsField("", title="Bottom Centered")
|
||||
BOTTOM_RIGHT: str = SettingsField("", title="Bottom Right")
|
||||
filter: ExtractBurninDefFilter = SettingsField(
|
||||
default_factory=ExtractBurninDefFilter,
|
||||
title="Additional filtering"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class ProductTypeSmartSelectModel(BaseSettingsModel):
|
|||
|
||||
class ProductNameProfile(BaseSettingsModel):
|
||||
_layout = "expanded"
|
||||
|
||||
product_types: list[str] = SettingsField(
|
||||
default_factory=list, title="Product types"
|
||||
)
|
||||
|
|
@ -65,6 +66,15 @@ class CreatorToolModel(BaseSettingsModel):
|
|||
title="Create Smart Select"
|
||||
)
|
||||
)
|
||||
# TODO: change to False in next releases
|
||||
use_legacy_product_names_for_renders: bool = SettingsField(
|
||||
True,
|
||||
title="Use legacy product names for renders",
|
||||
description="Use product naming templates for renders. "
|
||||
"This is for backwards compatibility enabled by default."
|
||||
"When enabled, it will ignore any templates for renders "
|
||||
"that are set in the product name profiles.")
|
||||
|
||||
product_name_profiles: list[ProductNameProfile] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Product name profiles"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue