Merge branch 'release/3.15.x' into enhancement/remove-staging-logic-on-version

This commit is contained in:
Jakub Trllo 2022-10-25 11:40:12 +02:00
commit 7c1c276f4d
31 changed files with 995 additions and 253 deletions

View file

@ -1,8 +1,40 @@
# Changelog
## [3.14.4](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.14.5](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.3...HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.4...HEAD)
**🚀 Enhancements**
- Maya: add OBJ extractor to model family [\#4021](https://github.com/pypeclub/OpenPype/pull/4021)
- Publish report viewer tool [\#4010](https://github.com/pypeclub/OpenPype/pull/4010)
- Nuke | Global: adding custom tags representation filtering [\#4009](https://github.com/pypeclub/OpenPype/pull/4009)
- Publisher: Create context has shared data for collection phase [\#3995](https://github.com/pypeclub/OpenPype/pull/3995)
- Resolve: updating to v18 compatibility [\#3986](https://github.com/pypeclub/OpenPype/pull/3986)
**🐛 Bug fixes**
- TrayPublisher: Fix missing argument [\#4019](https://github.com/pypeclub/OpenPype/pull/4019)
- General: Fix python 2 compatibility of ffmpeg and oiio tools discovery [\#4011](https://github.com/pypeclub/OpenPype/pull/4011)
**🔀 Refactored code**
- Maya: Removed unused imports [\#4008](https://github.com/pypeclub/OpenPype/pull/4008)
- Unreal: Fix import of moved function [\#4007](https://github.com/pypeclub/OpenPype/pull/4007)
- Houdini: Change import of RepairAction [\#4005](https://github.com/pypeclub/OpenPype/pull/4005)
- Nuke/Hiero: Refactor openpype.api imports [\#4000](https://github.com/pypeclub/OpenPype/pull/4000)
- TVPaint: Defined with HostBase [\#3994](https://github.com/pypeclub/OpenPype/pull/3994)
**Merged pull requests:**
- Unreal: Remove redundant Creator stub [\#4012](https://github.com/pypeclub/OpenPype/pull/4012)
- Unreal: add `uproject` extension to Unreal project template [\#4004](https://github.com/pypeclub/OpenPype/pull/4004)
- Unreal: fix order of includes [\#4002](https://github.com/pypeclub/OpenPype/pull/4002)
- Fusion: Implement backwards compatibility \(+/- Fusion 17.2\) [\#3958](https://github.com/pypeclub/OpenPype/pull/3958)
## [3.14.4](https://github.com/pypeclub/OpenPype/tree/3.14.4) (2022-10-19)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.3...3.14.4)
**🆕 New features**
@ -27,7 +59,6 @@
- Maya: Moved plugin from global to maya [\#3939](https://github.com/pypeclub/OpenPype/pull/3939)
- Publisher: Create dialog is part of main window [\#3936](https://github.com/pypeclub/OpenPype/pull/3936)
- Fusion: Implement Alembic and FBX mesh loader [\#3927](https://github.com/pypeclub/OpenPype/pull/3927)
- Maya: Remove hardcoded requirement for maya/ start for image file prefix [\#3873](https://github.com/pypeclub/OpenPype/pull/3873)
**🐛 Bug fixes**
@ -71,14 +102,6 @@
**🚀 Enhancements**
- Publisher: Enhancement proposals [\#3897](https://github.com/pypeclub/OpenPype/pull/3897)
- Maya: better logging in Maketx [\#3886](https://github.com/pypeclub/OpenPype/pull/3886)
- Photoshop: review can be turned off [\#3885](https://github.com/pypeclub/OpenPype/pull/3885)
- TrayPublisher: added persisting of last selected project [\#3871](https://github.com/pypeclub/OpenPype/pull/3871)
- TrayPublisher: added text filter on project name to Tray Publisher [\#3867](https://github.com/pypeclub/OpenPype/pull/3867)
- Github issues adding `running version` section [\#3864](https://github.com/pypeclub/OpenPype/pull/3864)
- Publisher: Increase size of main window [\#3862](https://github.com/pypeclub/OpenPype/pull/3862)
- Flame: make migratable projects after creation [\#3860](https://github.com/pypeclub/OpenPype/pull/3860)
- Photoshop: synchronize image version with workfile [\#3854](https://github.com/pypeclub/OpenPype/pull/3854)
**🐛 Bug fixes**
@ -86,12 +109,6 @@
- Flame: loading multilayer exr to batch/reel is working [\#3901](https://github.com/pypeclub/OpenPype/pull/3901)
- Hiero: Fix inventory check on launch [\#3895](https://github.com/pypeclub/OpenPype/pull/3895)
- WebPublisher: Fix import after refactor [\#3891](https://github.com/pypeclub/OpenPype/pull/3891)
- TVPaint: Fix renaming of rendered files [\#3882](https://github.com/pypeclub/OpenPype/pull/3882)
- Publisher: Nice checkbox visible in Python 2 [\#3877](https://github.com/pypeclub/OpenPype/pull/3877)
- Settings: Add missing default settings [\#3870](https://github.com/pypeclub/OpenPype/pull/3870)
- General: Copy of workfile does not use 'copy' function but 'copyfile' [\#3869](https://github.com/pypeclub/OpenPype/pull/3869)
- Tray Publisher: skip plugin if otioTimeline is missing [\#3856](https://github.com/pypeclub/OpenPype/pull/3856)
- Flame: retimed attributes are integrated with settings [\#3855](https://github.com/pypeclub/OpenPype/pull/3855)
**🔀 Refactored code**
@ -105,8 +122,6 @@
**Merged pull requests:**
- Maya: Fix Scene Inventory possibly starting off-screen due to maya preferences [\#3923](https://github.com/pypeclub/OpenPype/pull/3923)
- Maya: RenderSettings set default image format for V-Ray+Redshift to exr [\#3879](https://github.com/pypeclub/OpenPype/pull/3879)
- Remove lockfile during publish [\#3874](https://github.com/pypeclub/OpenPype/pull/3874)
## [3.14.2](https://github.com/pypeclub/OpenPype/tree/3.14.2) (2022-09-12)

View file

@ -251,7 +251,6 @@ def reload_config():
import importlib
for module in (
"openpype.api",
"openpype.hosts.hiero.lib",
"openpype.hosts.hiero.menu",
"openpype.hosts.hiero.tags"

View file

@ -1,5 +1,6 @@
from pyblish import api
import openpype.api as pype
from openpype.lib import version_up
class IntegrateVersionUpWorkfile(api.ContextPlugin):
@ -15,7 +16,7 @@ class IntegrateVersionUpWorkfile(api.ContextPlugin):
def process(self, context):
project = context.data["activeProject"]
path = context.data.get("currentFile")
new_path = pype.version_up(path)
new_path = version_up(path)
if project:
project.saveAs(new_path)

View file

View file

@ -90,7 +90,7 @@ class ImportMayaLoader(load.LoaderPlugin):
so you could also use it as a new base.
"""
representations = ["ma", "mb"]
representations = ["ma", "mb", "obj"]
families = ["*"]
label = "Import"

View file

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
import os
from maya import cmds
# import maya.mel as mel
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.maya.api import lib
class ExtractObj(publish.Extractor):
"""Extract OBJ from Maya.
This extracts reproducible OBJ exports ignoring any of the settings
set on the local machine in the OBJ export options window.
"""
order = pyblish.api.ExtractorOrder
hosts = ["maya"]
label = "Extract OBJ"
families = ["model"]
def process(self, instance):
# Define output path
staging_dir = self.staging_dir(instance)
filename = "{0}.obj".format(instance.name)
path = os.path.join(staging_dir, filename)
# The export requires forward slashes because we need to
# format it into a string in a mel expression
self.log.info("Extracting OBJ to: {0}".format(path))
members = instance.data("setMembers")
members = cmds.ls(members,
dag=True,
shapes=True,
type=("mesh", "nurbsCurve"),
noIntermediate=True,
long=True)
self.log.info("Members: {0}".format(members))
self.log.info("Instance: {0}".format(instance[:]))
if not cmds.pluginInfo('objExport', query=True, loaded=True):
cmds.loadPlugin('objExport')
# Export
with lib.no_display_layers(instance):
with lib.displaySmoothness(members,
divisionsU=0,
divisionsV=0,
pointsWire=4,
pointsShaded=1,
polygonObject=1):
with lib.shader(members,
shadingEngine="initialShadingGroup"):
with lib.maintained_selection():
cmds.select(members, noExpand=True)
cmds.file(path,
exportSelected=True,
type='OBJexport',
preserveReferences=True,
force=True)
if "representation" not in instance.data:
instance.data["representation"] = []
representation = {
'name': 'obj',
'ext': 'obj',
'files': filename,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)
self.log.info("Extract OBJ successful to: {0}".format(path))

View file

@ -2930,3 +2930,47 @@ def get_nodes_by_names(names):
nuke.toNode(name)
for name in names
]
def get_viewer_config_from_string(input_string):
"""Convert string to display and viewer string
Args:
input_string (str): string with viewer
Raises:
IndexError: if more then one slash in input string
IndexError: if missing closing bracket
Returns:
tuple[str]: display, viewer
"""
display = None
viewer = input_string
# check if () or / or \ in name
if "/" in viewer:
split = viewer.split("/")
# rise if more then one column
if len(split) > 2:
raise IndexError((
"Viewer Input string is not correct. "
"more then two `/` slashes! {}"
).format(input_string))
viewer = split[1]
display = split[0]
elif "(" in viewer:
pattern = r"([\w\d\s]+).*[(](.*)[)]"
result = re.findall(pattern, viewer)
try:
result = result.pop()
display = str(result[1]).rstrip()
viewer = str(result[0]).rstrip()
except IndexError:
raise IndexError((
"Viewer Input string is not correct. "
"Missing bracket! {}"
).format(input_string))
return (display, viewer)

View file

@ -66,7 +66,6 @@ def reload_config():
"""
for module in (
"openpype.api",
"openpype.hosts.nuke.api.actions",
"openpype.hosts.nuke.api.menu",
"openpype.hosts.nuke.api.plugin",

View file

@ -19,7 +19,8 @@ from .lib import (
add_publish_knob,
get_nuke_imageio_settings,
set_node_knobs_from_settings,
get_view_process_node
get_view_process_node,
get_viewer_config_from_string
)
@ -190,7 +191,20 @@ class ExporterReview(object):
if "#" in self.fhead:
self.fhead = self.fhead.replace("#", "")[:-1]
def get_representation_data(self, tags=None, range=False):
def get_representation_data(
self, tags=None, range=False,
custom_tags=None
):
""" Add representation data to self.data
Args:
tags (list[str], optional): list of defined tags.
Defaults to None.
range (bool, optional): flag for adding ranges.
Defaults to False.
custom_tags (list[str], optional): user inputed custom tags.
Defaults to None.
"""
add_tags = tags or []
repre = {
"name": self.name,
@ -200,6 +214,9 @@ class ExporterReview(object):
"tags": [self.name.replace("_", "-")] + add_tags
}
if custom_tags:
repre["custom_tags"] = custom_tags
if range:
repre.update({
"frameStart": self.first_frame,
@ -312,7 +329,8 @@ class ExporterReviewLut(ExporterReview):
dag_node.setInput(0, self.previous_node)
self._temp_nodes.append(dag_node)
self.previous_node = dag_node
self.log.debug("OCIODisplay... `{}`".format(self._temp_nodes))
self.log.debug(
"OCIODisplay... `{}`".format(self._temp_nodes))
# GenerateLUT
gen_lut_node = nuke.createNode("GenerateLUT")
@ -415,6 +433,7 @@ class ExporterReviewMov(ExporterReview):
return path
def generate_mov(self, farm=False, **kwargs):
add_tags = []
self.publish_on_farm = farm
read_raw = kwargs["read_raw"]
reformat_node_add = kwargs["reformat_node_add"]
@ -433,10 +452,10 @@ class ExporterReviewMov(ExporterReview):
self.log.debug(">> baking_view_profile `{}`".format(
baking_view_profile))
add_tags = kwargs.get("add_tags", [])
add_custom_tags = kwargs.get("add_custom_tags", [])
self.log.info(
"__ add_tags: `{0}`".format(add_tags))
"__ add_custom_tags: `{0}`".format(add_custom_tags))
subset = self.instance.data["subset"]
self._temp_nodes[subset] = []
@ -491,7 +510,15 @@ class ExporterReviewMov(ExporterReview):
if not self.viewer_lut_raw:
# OCIODisplay
dag_node = nuke.createNode("OCIODisplay")
dag_node["view"].setValue(str(baking_view_profile))
display, viewer = get_viewer_config_from_string(
str(baking_view_profile)
)
if display:
dag_node["display"].setValue(display)
# assign viewer
dag_node["view"].setValue(viewer)
# connect
dag_node.setInput(0, self.previous_node)
@ -542,6 +569,7 @@ class ExporterReviewMov(ExporterReview):
# ---------- generate representation data
self.get_representation_data(
tags=["review", "delete"] + add_tags,
custom_tags=add_custom_tags,
range=True
)

View file

@ -3,7 +3,8 @@ import os
import nuke
import pyblish.api
import openpype.api as pype
from openpype.lib import get_version_from_path
from openpype.hosts.nuke.api.lib import (
add_publish_knob,
get_avalon_knob_data
@ -74,7 +75,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
"fps": root['fps'].value(),
"currentFile": current_file,
"version": int(pype.get_version_from_path(current_file)),
"version": int(get_version_from_path(current_file)),
"host": pyblish.api.current_host(),
"hostVersion": nuke.NUKE_VERSION_STRING

View file

@ -17,11 +17,27 @@ from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
REVIEW_EXTENSIONS = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS
def _cache_and_get_instances(creator):
"""Cache instances in shared data.
Args:
creator (Creator): Plugin which would like to get instances from host.
Returns:
List[Dict[str, Any]]: Cached instances list from host implementation.
"""
shared_key = "openpype.traypublisher.instances"
if shared_key not in creator.collection_shared_data:
creator.collection_shared_data[shared_key] = list_instances()
return creator.collection_shared_data[shared_key]
class HiddenTrayPublishCreator(HiddenCreator):
host_name = "traypublisher"
def collect_instances(self):
for instance_data in list_instances():
for instance_data in _cache_and_get_instances(self):
creator_id = instance_data.get("creator_identifier")
if creator_id == self.identifier:
instance = CreatedInstance.from_existing(
@ -58,7 +74,7 @@ class TrayPublishCreator(Creator):
host_name = "traypublisher"
def collect_instances(self):
for instance_data in list_instances():
for instance_data in _cache_and_get_instances(self):
creator_id = instance_data.get("creator_identifier")
if creator_id == self.identifier:
instance = CreatedInstance.from_existing(

View file

@ -1,6 +1,8 @@
import os
import sys
import copy
import logging
import traceback
import collections
import inspect
from uuid import uuid4
@ -22,11 +24,17 @@ from .creator_plugins import (
Creator,
AutoCreator,
discover_creator_plugins,
CreatorError,
)
UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"])
class UnavailableSharedData(Exception):
"""Shared data are not available at the moment when are accessed."""
pass
class ImmutableKeyError(TypeError):
"""Accessed key is immutable so does not allow changes or removements."""
@ -62,6 +70,77 @@ class HostMissRequiredMethod(Exception):
super(HostMissRequiredMethod, self).__init__(msg)
class CreatorsOperationFailed(Exception):
"""Raised when a creator process crashes in 'CreateContext'.
The exception contains information about the creator and error. The data
are prepared using 'prepare_failed_creator_operation_info' and can be
serialized using json.
Usage is for UI purposes which may not have access to exceptions directly
and would not have ability to catch exceptions 'per creator'.
Args:
msg (str): General error message.
failed_info (list[dict[str, Any]]): List of failed creators with
exception message and optionally formatted traceback.
"""
def __init__(self, msg, failed_info):
super(CreatorsOperationFailed, self).__init__(msg)
self.failed_info = failed_info
class CreatorsCollectionFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to collect instances"
super(CreatorsCollectionFailed, self).__init__(
msg, failed_info
)
class CreatorsSaveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed update instance changes"
super(CreatorsSaveFailed, self).__init__(
msg, failed_info
)
class CreatorsRemoveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to remove instances"
super(CreatorsRemoveFailed, self).__init__(
msg, failed_info
)
class CreatorsCreateFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Faled to create instances"
super(CreatorsCreateFailed, self).__init__(
msg, failed_info
)
def prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback=True
):
formatted_traceback = None
exc_type, exc_value, exc_traceback = exc_info
if add_traceback:
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
return {
"creator_identifier": identifier,
"creator_label": label,
"message": str(exc_value),
"traceback": formatted_traceback
}
class InstanceMember:
"""Representation of instance member.
@ -925,6 +1004,9 @@ class CreateContext:
self._bulk_counter = 0
self._bulk_instances_to_process = []
# Shared data across creators during collection phase
self._collection_shared_data = None
# Trigger reset if was enabled
if reset:
self.reset(discover_publish_plugins)
@ -980,6 +1062,9 @@ class CreateContext:
All changes will be lost if were not saved explicitely.
"""
self.reset_preparation()
self.reset_avalon_context()
self.reset_plugins(discover_publish_plugins)
self.reset_context_data()
@ -988,6 +1073,20 @@ class CreateContext:
self.reset_instances()
self.execute_autocreators()
self.reset_finalization()
def reset_preparation(self):
"""Prepare attributes that must be prepared/cleaned before reset."""
# Give ability to store shared data for collection phase
self._collection_shared_data = {}
def reset_finalization(self):
"""Cleanup of attributes after reset."""
# Stop access to collection shared data
self._collection_shared_data = None
def reset_avalon_context(self):
"""Give ability to reset avalon context.
@ -1186,7 +1285,65 @@ class CreateContext:
with self.bulk_instances_collection():
self._bulk_instances_to_process.append(instance)
def create(self, identifier, *args, **kwargs):
"""Wrapper for creators to trigger created.
Different types of creators may expect different arguments thus the
hints for args are blind.
Args:
identifier (str): Creator's identifier.
*args (Tuple[Any]): Arguments for create method.
**kwargs (Dict[Any, Any]): Keyword argument for create method.
"""
error_message = "Failed to run Creator with identifier \"{}\". {}"
creator = self.creators.get(identifier)
label = getattr(creator, "label", None)
failed = False
add_traceback = False
exc_info = None
try:
# Fake CreatorError (Could be maybe specific exception?)
if creator is None:
raise CreatorError(
"Creator {} was not found".format(identifier)
)
creator.create(*args, **kwargs)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
raise CreatorsCreateFailed([
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
])
def creator_removed_instance(self, instance):
"""When creator removes instance context should be acknowledged.
If creator removes instance conext should know about it to avoid
possible issues in the session.
Args:
instance (CreatedInstance): Object of instance which was removed
from scene metadata.
"""
self._instances_by_id.pop(instance.id, None)
@contextmanager
@ -1221,24 +1378,81 @@ class CreateContext:
self._instances_by_id = {}
# Collect instances
error_message = "Collection of instances for creator {} failed. {}"
failed_info = []
for creator in self.creators.values():
creator.collect_instances()
label = creator.label
identifier = creator.identifier
failed = False
add_traceback = False
exc_info = None
try:
creator.collect_instances()
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsCollectionFailed(failed_info)
def execute_autocreators(self):
"""Execute discovered AutoCreator plugins.
Reset instances if any autocreator executed properly.
"""
error_message = "Failed to run AutoCreator with identifier \"{}\". {}"
failed_info = []
for identifier, creator in self.autocreators.items():
label = creator.label
failed = False
add_traceback = False
try:
creator.create()
except Exception:
# TODO raise report exception if any crashed
msg = (
"Failed to run AutoCreator with identifier \"{}\" ({})."
).format(identifier, inspect.getfile(creator.__class__))
self.log.warning(msg, exc_info=True)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
# Use bare except because some hosts raise their exceptions that
# do not inherit from python's `BaseException`
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsCreateFailed(failed_info)
def validate_instances_context(self, instances=None):
"""Validate 'asset' and 'task' instance context."""
@ -1315,17 +1529,48 @@ class CreateContext:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
for identifier, cretor_instances in instances_by_identifier.items():
error_message = "Instances update of creator \"{}\" failed. {}"
failed_info = []
for identifier, creator_instances in instances_by_identifier.items():
update_list = []
for instance in cretor_instances:
for instance in creator_instances:
instance_changes = instance.changes()
if instance_changes:
update_list.append(UpdateData(instance, instance_changes))
creator = self.creators[identifier]
if update_list:
if not update_list:
continue
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.update_instances(update_list)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""), exc_info=True)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsSaveFailed(failed_info)
def remove_instances(self, instances):
"""Remove instances from context.
@ -1333,14 +1578,48 @@ class CreateContext:
instances(list<CreatedInstance>): Instances that should be removed
from context.
"""
instances_by_identifier = collections.defaultdict(list)
for instance in instances:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
for identifier, creator_instances in instances_by_identifier.items():
creator = self.creators.get(identifier)
creator.remove_instances(creator_instances)
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsRemoveFailed(failed_info)
def _get_publish_plugins_with_attr_for_family(self, family):
"""Publish plugin attributes for passed family.
@ -1372,3 +1651,20 @@ class CreateContext:
if not plugin.__instanceEnabled__:
plugins.append(plugin)
return plugins
@property
def collection_shared_data(self):
"""Access to shared data that can be used during creator's collection.
Retruns:
Dict[str, Any]: Shared data.
Raises:
UnavailableSharedData: When called out of collection phase.
"""
if self._collection_shared_data is None:
raise UnavailableSharedData(
"Accessed Collection shared data out of collection phase"
)
return self._collection_shared_data

View file

@ -6,6 +6,7 @@ from abc import (
abstractmethod,
abstractproperty
)
import six
from openpype.settings import get_system_settings, get_project_settings
@ -323,6 +324,19 @@ class BaseCreator:
return self.instance_attr_defs
@property
def collection_shared_data(self):
"""Access to shared data that can be used during creator's collection.
Retruns:
Dict[str, Any]: Shared data.
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self.create_context.collection_shared_data
class Creator(BaseCreator):
"""Creator that has more information for artist to show in UI.

View file

@ -128,6 +128,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
for repre in instance.data["representations"]:
repre_name = str(repre.get("name"))
tags = repre.get("tags") or []
custom_tags = repre.get("custom_tags")
if "review" not in tags:
self.log.debug((
"Repre: {} - Didn't found \"review\" in tags. Skipping"
@ -158,15 +159,18 @@ class ExtractReview(pyblish.api.InstancePlugin):
)
continue
# Filter output definition by representation tags (optional)
outputs = self.filter_outputs_by_tags(profile_outputs, tags)
# Filter output definition by representation's
# custom tags (optional)
outputs = self.filter_outputs_by_custom_tags(
profile_outputs, custom_tags)
if not outputs:
self.log.info((
"Skipped representation. All output definitions from"
" selected profile does not match to representation's"
" tags. \"{}\""
" custom tags. \"{}\""
).format(str(tags)))
continue
outputs_per_representations.append((repre, outputs))
return outputs_per_representations
@ -1656,7 +1660,9 @@ class ExtractReview(pyblish.api.InstancePlugin):
return True
return False
def filter_output_defs(self, profile, subset_name, families):
def filter_output_defs(
self, profile, subset_name, families
):
"""Return outputs matching input instance families.
Output definitions without families filter are marked as valid.
@ -1664,6 +1670,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
Args:
profile (dict): Profile from presets matching current context.
families (list): All families of current instance.
subset_name (str): name of subset
Returns:
list: Containg all output definitions matching entered families.
@ -1711,40 +1718,51 @@ class ExtractReview(pyblish.api.InstancePlugin):
return filtered_outputs
def filter_outputs_by_tags(self, outputs, tags):
"""Filter output definitions by entered representation tags.
def filter_outputs_by_custom_tags(self, outputs, custom_tags):
"""Filter output definitions by entered representation custom_tags.
Output definitions without tags filter are marked as valid.
Output definitions without custom_tags filter are marked as invalid,
only in case representation is having any custom_tags defined.
Args:
outputs (list): Contain list of output definitions from presets.
tags (list): Tags of processed representation.
custom_tags (list): Custom Tags of processed representation.
Returns:
list: Containg all output definitions matching entered tags.
"""
filtered_outputs = []
repre_tags_low = [tag.lower() for tag in tags]
for output_def in outputs:
valid = True
output_filters = output_def.get("filter")
if output_filters:
# Check tag filters
tag_filters = output_filters.get("tags")
if tag_filters:
tag_filters_low = [tag.lower() for tag in tag_filters]
valid = False
for tag in repre_tags_low:
if tag in tag_filters_low:
valid = True
break
if not valid:
continue
filtered_outputs = []
repre_c_tags_low = [tag.lower() for tag in (custom_tags or [])]
for output_def in outputs:
tag_filters = output_def.get("filter", {}).get("custom_tags")
if not custom_tags and not tag_filters:
# Definition is valid if both tags are empty
valid = True
elif not custom_tags or not tag_filters:
# Invalid if one is empty
valid = False
else:
# Check if output definition tags are in representation tags
valid = False
# lower all filter tags
tag_filters_low = [tag.lower() for tag in tag_filters]
# check if any repre tag is not in filter tags
for tag in repre_c_tags_low:
if tag in tag_filters_low:
valid = True
break
if valid:
filtered_outputs.append(output_def)
self.log.debug("__ filtered_outputs: {}".format(
[_o["filename_suffix"] for _o in filtered_outputs]
))
return filtered_outputs
def add_video_filter_args(self, args, inserting_arg):

View file

@ -22,10 +22,6 @@ FFMPEG = (
'"{}"%(input_args)s -i "%(input)s" %(filters)s %(args)s%(output)s'
).format(ffmpeg_path)
FFPROBE = (
'"{}" -v quiet -print_format json -show_format -show_streams "%(source)s"'
).format(ffprobe_path)
DRAWTEXT = (
"drawtext=fontfile='%(font)s':text=\\'%(text)s\\':"
"x=%(x)s:y=%(y)s:fontcolor=%(color)s@%(opacity).1f:fontsize=%(size)d"
@ -48,8 +44,15 @@ def _get_ffprobe_data(source):
:param str source: source media file
:rtype: [{}, ...]
"""
command = FFPROBE % {'source': source}
proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE)
command = [
ffprobe_path,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
source
]
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
out = proc.communicate()[0]
if proc.returncode != 0:
raise RuntimeError("Failed to run: %s" % command)
@ -113,11 +116,20 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
if not ffprobe_data:
ffprobe_data = _get_ffprobe_data(source)
# Validate 'streams' before calling super to raise more specific
# error
source_streams = ffprobe_data.get("streams")
if not source_streams:
raise ValueError((
"Input file \"{}\" does not contain any streams"
" with image/video content."
).format(source))
self.ffprobe_data = ffprobe_data
self.first_frame = first_frame
self.input_args = []
super().__init__(source, ffprobe_data["streams"])
super().__init__(source, source_streams)
if options_init:
self.options_init.update(options_init)

View file

@ -78,7 +78,8 @@
"review",
"ftrack"
],
"subsets": []
"subsets": [],
"custom_tags": []
},
"overscan_crop": "",
"overscan_color": [

View file

@ -131,6 +131,16 @@
"Main"
]
},
"CreateModel": {
"enabled": true,
"write_color_sets": false,
"write_face_sets": false,
"defaults": [
"Main",
"Proxy",
"Sculpt"
]
},
"CreatePointCache": {
"enabled": true,
"write_color_sets": false,
@ -187,16 +197,6 @@
"Main"
]
},
"CreateModel": {
"enabled": true,
"write_color_sets": false,
"write_face_sets": false,
"defaults": [
"Main",
"Proxy",
"Sculpt"
]
},
"CreateRenderSetup": {
"enabled": true,
"defaults": [
@ -577,6 +577,10 @@
"vrayproxy"
]
},
"ExtractObj": {
"enabled": false,
"optional": true
},
"ValidateRigContents": {
"enabled": false,
"optional": true,

View file

@ -434,7 +434,7 @@
}
],
"extension": "mov",
"add_tags": []
"add_custom_tags": []
}
}
},

View file

@ -295,6 +295,15 @@
"label": "Subsets",
"type": "list",
"object_type": "text"
},
{
"type": "separator"
},
{
"key": "custom_tags",
"label": "Custom Tags",
"type": "list",
"object_type": "text"
}
]
},

View file

@ -657,6 +657,25 @@
"object_type": "text"
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "ExtractObj",
"label": "Extract OBJ",
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
}
]
}
]
},

View file

@ -296,8 +296,8 @@
"label": "Write node file type"
},
{
"key": "add_tags",
"label": "Add additional tags to representations",
"key": "add_custom_tags",
"label": "Add custom tags",
"type": "list",
"object_type": "text"
}

View file

@ -31,6 +31,9 @@ from openpype.pipeline.create import (
HiddenCreator,
Creator,
)
from openpype.pipeline.create.context import (
CreatorsOperationFailed,
)
# Define constant for plugin orders offset
PLUGIN_ORDER_OFFSET = 0.5
@ -299,8 +302,11 @@ class PublishReport:
}
def _extract_context_data(self, context):
context_label = "Context"
if context is not None:
context_label = context.data.get("label")
return {
"label": context.data.get("label")
"label": context_label
}
def _extract_instance_data(self, instance, exists):
@ -1101,6 +1107,8 @@ class AbstractPublisherController(object):
options (Dict[str, Any]): Data from pre-create attributes.
"""
pass
def save_changes(self):
"""Save changes in create context."""
@ -1662,13 +1670,12 @@ class PublisherController(BasePublisherController):
def reset(self):
"""Reset everything related to creation and publishing."""
# Stop publishing
self.stop_publish()
self.save_changes()
self.host_is_valid = self._create_context.host_is_valid
self._create_context.reset_preparation()
# Reset avalon context
self._create_context.reset_avalon_context()
@ -1679,6 +1686,8 @@ class PublisherController(BasePublisherController):
self._reset_publish()
self._reset_instances()
self._create_context.reset_finalization()
self._emit_event("controller.reset.finished")
self.emit_card_message("Refreshed..")
@ -1711,8 +1720,28 @@ class PublisherController(BasePublisherController):
self._create_context.reset_context_data()
with self._create_context.bulk_instances_collection():
self._create_context.reset_instances()
self._create_context.execute_autocreators()
try:
self._create_context.reset_instances()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.collection.failed",
{
"title": "Instance collection failed",
"failed_info": exc.failed_info
}
)
try:
self._create_context.execute_autocreators()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.create.failed",
{
"title": "AutoCreation failed",
"failed_info": exc.failed_info
}
)
self._resetting_instances = False
@ -1841,16 +1870,42 @@ class PublisherController(BasePublisherController):
self, creator_identifier, subset_name, instance_data, options
):
"""Trigger creation and refresh of instances in UI."""
creator = self._creators[creator_identifier]
creator.create(subset_name, instance_data, options)
success = True
try:
self._create_context.create(
creator_identifier, subset_name, instance_data, options
)
except CreatorsOperationFailed as exc:
success = False
self._emit_event(
"instances.create.failed",
{
"title": "Creation failed",
"failed_info": exc.failed_info
}
)
self._on_create_instance_change()
return success
def save_changes(self):
"""Save changes happened during creation."""
if self._create_context.host_is_valid:
if not self._create_context.host_is_valid:
return
try:
self._create_context.save_changes()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.save.failed",
{
"title": "Instances save failed",
"failed_info": exc.failed_info
}
)
def remove_instances(self, instance_ids):
"""Remove instances based on instance ids.
@ -1872,7 +1927,16 @@ class PublisherController(BasePublisherController):
instances_by_id[instance_id]
for instance_id in instance_ids
]
self._create_context.remove_instances(instances)
try:
self._create_context.remove_instances(instances)
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.remove.failed",
{
"title": "Instance removement failed",
"failed_info": exc.failed_info
}
)
def _on_create_instance_change(self):
self._emit_event("instances.refresh.finished")

View file

@ -9,7 +9,6 @@ from openpype.pipeline.create import (
SUBSET_NAME_ALLOWED_SYMBOLS,
TaskNotSetError,
)
from openpype.tools.utils import ErrorMessageBox
from .widgets import (
IconValuePixmapLabel,
@ -35,79 +34,6 @@ class VariantInputsWidget(QtWidgets.QWidget):
self.resized.emit()
class CreateErrorMessageBox(ErrorMessageBox):
def __init__(
self,
creator_label,
subset_name,
asset_name,
exc_msg,
formatted_traceback,
parent
):
self._creator_label = creator_label
self._subset_name = subset_name
self._asset_name = asset_name
self._exc_msg = exc_msg
self._formatted_traceback = formatted_traceback
super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
label_widget.setText(
"<span style='font-size:18pt;'>Failed to create</span>"
)
return label_widget
def _get_report_data(self):
report_message = (
"{creator}: Failed to create Subset: \"{subset}\""
" in Asset: \"{asset}\""
"\n\nError: {message}"
).format(
creator=self._creator_label,
subset=self._subset_name,
asset=self._asset_name,
message=self._exc_msg,
)
if self._formatted_traceback:
report_message += "\n\n{}".format(self._formatted_traceback)
return [report_message]
def _create_content(self, content_layout):
item_name_template = (
"<span style='font-weight:bold;'>Creator:</span> {}<br>"
"<span style='font-weight:bold;'>Subset:</span> {}<br>"
"<span style='font-weight:bold;'>Asset:</span> {}<br>"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
line = self._create_line()
content_layout.addWidget(line)
item_name_widget = QtWidgets.QLabel(self)
item_name_widget.setText(
item_name_template.format(
self._creator_label, self._subset_name, self._asset_name
)
)
content_layout.addWidget(item_name_widget)
message_label_widget = QtWidgets.QLabel(self)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
)
content_layout.addWidget(message_label_widget)
if self._formatted_traceback:
line_widget = self._create_line()
tb_widget = self._create_traceback_widget(
self._formatted_traceback
)
content_layout.addWidget(line_widget)
content_layout.addWidget(tb_widget)
# TODO add creator identifier/label to details
class CreatorShortDescWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
@ -178,8 +104,6 @@ class CreateWidget(QtWidgets.QWidget):
self._prereq_available = False
self._message_dialog = None
name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS)
self._name_pattern = name_pattern
self._compiled_name_pattern = re.compile(name_pattern)
@ -769,7 +693,6 @@ class CreateWidget(QtWidgets.QWidget):
return
index = indexes[0]
creator_label = index.data(QtCore.Qt.DisplayRole)
creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE)
family = index.data(FAMILY_ROLE)
variant = self.variant_input.text()
@ -792,40 +715,13 @@ class CreateWidget(QtWidgets.QWidget):
"family": family
}
error_msg = None
formatted_traceback = None
try:
self._controller.create(
creator_identifier,
subset_name,
instance_data,
pre_create_data
)
success = self._controller.create(
creator_identifier,
subset_name,
instance_data,
pre_create_data
)
except CreatorError as exc:
error_msg = str(exc)
# Use bare except because some hosts raise their exceptions that
# do not inherit from python's `BaseException`
except:
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
error_msg = str(exc_value)
if error_msg is None:
if success:
self._set_creator(self._selected_creator)
self._controller.emit_card_message("Creation finished...")
else:
box = CreateErrorMessageBox(
creator_label,
subset_name,
asset_name,
error_msg,
formatted_traceback,
parent=self
)
box.show()
# Store dialog so is not garbage collected before is shown
self._message_dialog = box

View file

@ -93,8 +93,8 @@ class OverviewWidget(QtWidgets.QFrame):
main_layout.addWidget(subset_content_widget, 1)
change_anim = QtCore.QVariantAnimation()
change_anim.setStartValue(0)
change_anim.setEndValue(self.anim_end_value)
change_anim.setStartValue(float(0))
change_anim.setEndValue(float(self.anim_end_value))
change_anim.setDuration(self.anim_duration)
change_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad)
@ -264,9 +264,10 @@ class OverviewWidget(QtWidgets.QFrame):
+ (self._subset_content_layout.spacing() * 2)
)
)
subset_attrs_width = int(float(width) / self.anim_end_value) * value
subset_attrs_width = int((float(width) / self.anim_end_value) * value)
if subset_attrs_width > width:
subset_attrs_width = width
create_width = width - subset_attrs_width
self._create_widget.setMinimumWidth(create_width)

View file

@ -248,13 +248,13 @@ class PublishFrame(QtWidgets.QWidget):
hint = self._top_content_widget.minimumSizeHint()
end = hint.height()
self._shrunk_anim.setStartValue(start)
self._shrunk_anim.setEndValue(end)
self._shrunk_anim.setStartValue(float(start))
self._shrunk_anim.setEndValue(float(end))
if not anim_is_running:
self._shrunk_anim.start()
def _on_shrunk_anim(self, value):
diff = self._top_content_widget.height() - value
diff = self._top_content_widget.height() - int(value)
if not self._top_content_widget.isVisible():
diff -= self._content_layout.spacing()

View file

@ -1,3 +1,4 @@
import collections
from Qt import QtWidgets, QtCore, QtGui
from openpype import (
@ -5,6 +6,7 @@ from openpype import (
style
)
from openpype.tools.utils import (
ErrorMessageBox,
PlaceholderLineEdit,
MessageOverlayObject,
PixmapLabel,
@ -222,6 +224,12 @@ class PublisherWindow(QtWidgets.QDialog):
# Floating publish frame
publish_frame = PublishFrame(controller, self.footer_border, self)
creators_dialog_message_timer = QtCore.QTimer()
creators_dialog_message_timer.setInterval(100)
creators_dialog_message_timer.timeout.connect(
self._on_creators_message_timeout
)
help_btn.clicked.connect(self._on_help_click)
tabs_widget.tab_changed.connect(self._on_tab_change)
overview_widget.active_changed.connect(
@ -259,6 +267,18 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"show.card.message", self._on_overlay_message
)
controller.event_system.add_callback(
"instances.collection.failed", self._instance_collection_failed
)
controller.event_system.add_callback(
"instances.save.failed", self._instance_save_failed
)
controller.event_system.add_callback(
"instances.remove.failed", self._instance_remove_failed
)
controller.event_system.add_callback(
"instances.create.failed", self._instance_create_failed
)
# Store extra header widget for TrayPublisher
# - can be used to add additional widgets to header between context
@ -298,10 +318,16 @@ class PublisherWindow(QtWidgets.QDialog):
self._controller = controller
self._first_show = True
self._reset_on_show = reset_on_show
# This is a little bit confusing but 'reset_on_first_show' is too long
# forin init
self._reset_on_first_show = reset_on_show
self._reset_on_show = True
self._restart_timer = None
self._publish_frame_visible = None
self._creators_messages_to_show = collections.deque()
self._creators_dialog_message_timer = creators_dialog_message_timer
self._set_publish_visibility(False)
@property
@ -314,6 +340,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._first_show = False
self._on_first_show()
if not self._reset_on_show:
return
self._reset_on_show = False
# Detach showing - give OS chance to draw the window
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.setInterval(1)
timer.timeout.connect(self._on_show_restart_timer)
self._restart_timer = timer
timer.start()
def resizeEvent(self, event):
super(PublisherWindow, self).resizeEvent(event)
self._update_publish_frame_rect()
@ -324,16 +362,7 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_first_show(self):
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
if not self._reset_on_show:
return
# Detach showing - give OS chance to draw the window
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.setInterval(1)
timer.timeout.connect(self._on_show_restart_timer)
self._restart_timer = timer
timer.start()
self._reset_on_show = self._reset_on_first_show
def _on_show_restart_timer(self):
"""Callback for '_restart_timer' timer."""
@ -342,9 +371,13 @@ class PublisherWindow(QtWidgets.QDialog):
self.reset()
def closeEvent(self, event):
self._controller.save_changes()
self.save_changes()
self._reset_on_show = True
super(PublisherWindow, self).closeEvent(event)
def save_changes(self):
self._controller.save_changes()
def reset(self):
self._controller.reset()
@ -436,7 +469,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._update_publish_frame_rect()
def _on_reset_clicked(self):
self._controller.reset()
self.save_changes()
self.reset()
def _on_stop_clicked(self):
self._controller.stop_publish()
@ -472,7 +506,7 @@ class PublisherWindow(QtWidgets.QDialog):
self._update_publish_details_widget()
if (
not self._tabs_widget.is_current_tab("create")
or not self._tabs_widget.is_current_tab("publish")
and not self._tabs_widget.is_current_tab("publish")
):
self._tabs_widget.set_current_tab("publish")
@ -569,3 +603,129 @@ class PublisherWindow(QtWidgets.QDialog):
self._publish_frame.move(
0, window_size.height() - height
)
def add_message_dialog(self, title, failed_info):
self._creators_messages_to_show.append((title, failed_info))
self._creators_dialog_message_timer.start()
def _on_creators_message_timeout(self):
if not self._creators_messages_to_show:
self._creators_dialog_message_timer.stop()
return
item = self._creators_messages_to_show.popleft()
title, failed_info = item
dialog = CreatorsErrorMessageBox(title, failed_info, self)
dialog.exec_()
dialog.deleteLater()
def _instance_collection_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_save_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_remove_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_create_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
class CreatorsErrorMessageBox(ErrorMessageBox):
def __init__(self, error_title, failed_info, parent):
self._failed_info = failed_info
self._info_with_id = [
# Id must be string when used in tab widget
{"id": str(idx), "info": info}
for idx, info in enumerate(failed_info)
]
self._widgets_by_id = {}
self._tabs_widget = None
self._stack_layout = None
super(CreatorsErrorMessageBox, self).__init__(error_title, parent)
layout = self.layout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
footer_layout = self._footer_widget.layout()
footer_layout.setContentsMargins(5, 5, 5, 5)
def _create_top_widget(self, parent_widget):
return None
def _get_report_data(self):
output = []
for info in self._failed_info:
creator_label = info["creator_label"]
creator_identifier = info["creator_identifier"]
report_message = "Creator:"
if creator_label:
report_message += " {} ({})".format(
creator_label, creator_identifier)
else:
report_message += " {}".format(creator_identifier)
report_message += "\n\nError: {}".format(info["message"])
formatted_traceback = info["traceback"]
if formatted_traceback:
report_message += "\n\n{}".format(formatted_traceback)
output.append(report_message)
return output
def _create_content(self, content_layout):
tabs_widget = PublisherTabsWidget(self)
stack_widget = QtWidgets.QFrame(self._content_widget)
stack_layout = QtWidgets.QStackedLayout(stack_widget)
first = True
for item in self._info_with_id:
item_id = item["id"]
info = item["info"]
message = info["message"]
formatted_traceback = info["traceback"]
creator_label = info["creator_label"]
creator_identifier = info["creator_identifier"]
if not creator_label:
creator_label = creator_identifier
msg_widget = QtWidgets.QWidget(stack_widget)
msg_layout = QtWidgets.QVBoxLayout(msg_widget)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
message_label_widget = QtWidgets.QLabel(msg_widget)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(message))
)
msg_layout.addWidget(message_label_widget, 0)
if formatted_traceback:
line_widget = self._create_line(msg_widget)
tb_widget = self._create_traceback_widget(formatted_traceback)
msg_layout.addWidget(line_widget, 0)
msg_layout.addWidget(tb_widget, 0)
msg_layout.addStretch(1)
tabs_widget.add_tab(creator_label, item_id)
stack_layout.addWidget(msg_widget)
if first:
first = False
stack_layout.setCurrentWidget(msg_widget)
self._widgets_by_id[item_id] = msg_widget
content_layout.addWidget(tabs_widget, 0)
content_layout.addWidget(stack_widget, 1)
tabs_widget.tab_changed.connect(self._on_tab_change)
self._tabs_widget = tabs_widget
self._stack_layout = stack_layout
def _on_tab_change(self, old_identifier, identifier):
widget = self._widgets_by_id[identifier]
self._stack_layout.setCurrentWidget(widget)

View file

@ -7,6 +7,7 @@ from .widgets import (
ExpandBtn,
PixmapLabel,
IconButton,
SeparatorWidget,
)
from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
@ -37,6 +38,7 @@ __all__ = (
"ExpandBtn",
"PixmapLabel",
"IconButton",
"SeparatorWidget",
"DeselectableTreeView",

View file

@ -1,9 +1,9 @@
from Qt import QtWidgets, QtCore
from .widgets import ClickableFrame, ExpandBtn
from .widgets import ClickableFrame, ExpandBtn, SeparatorWidget
def convert_text_for_html(text):
def escape_text_for_html(text):
return (
text
.replace("<", "&#60;")
@ -19,7 +19,7 @@ class TracebackWidget(QtWidgets.QWidget):
# Modify text to match html
# - add more replacements when needed
tb_text = convert_text_for_html(tb_text)
tb_text = escape_text_for_html(tb_text)
expand_btn = ExpandBtn(self)
clickable_frame = ClickableFrame(self)
@ -85,17 +85,20 @@ class ErrorMessageBox(QtWidgets.QDialog):
copy_report_btn = QtWidgets.QPushButton("Copy report", self)
ok_btn = QtWidgets.QPushButton("OK", self)
footer_layout = QtWidgets.QHBoxLayout()
footer_widget = QtWidgets.QWidget(self)
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(copy_report_btn, 0)
footer_layout.addStretch(1)
footer_layout.addWidget(ok_btn, 0)
bottom_line = self._create_line()
body_layout = QtWidgets.QVBoxLayout(self)
body_layout.addWidget(top_widget, 0)
body_layout.addWidget(content_scroll, 1)
body_layout.addWidget(bottom_line, 0)
body_layout.addLayout(footer_layout, 0)
main_layout = QtWidgets.QVBoxLayout(self)
if top_widget is not None:
main_layout.addWidget(top_widget, 0)
main_layout.addWidget(content_scroll, 1)
main_layout.addWidget(bottom_line, 0)
main_layout.addWidget(footer_widget, 0)
copy_report_btn.clicked.connect(self._on_copy_report)
ok_btn.clicked.connect(self._on_ok_clicked)
@ -106,11 +109,13 @@ class ErrorMessageBox(QtWidgets.QDialog):
if not report_data:
copy_report_btn.setVisible(False)
self._content_scroll = content_scroll
self._footer_widget = footer_widget
self._report_data = report_data
@staticmethod
def convert_text_for_html(text):
return convert_text_for_html(text)
return escape_text_for_html(text)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
@ -131,7 +136,8 @@ class ErrorMessageBox(QtWidgets.QDialog):
self.close()
def _on_copy_report(self):
report_text = (10 * "*").join(self._report_data)
sep = "\n{}\n".format(10 * "*")
report_text = sep.join(self._report_data)
mime_data = QtCore.QMimeData()
mime_data.setText(report_text)
@ -139,12 +145,10 @@ class ErrorMessageBox(QtWidgets.QDialog):
mime_data
)
def _create_line(self):
line = QtWidgets.QFrame(self)
line.setObjectName("Separator")
line.setMinimumHeight(2)
line.setMaximumHeight(2)
return line
def _create_line(self, parent=None):
if parent is None:
parent = self
return SeparatorWidget(2, parent=parent)
def _create_traceback_widget(self, traceback_text, parent=None):
if parent is None:

View file

@ -448,3 +448,57 @@ class OptionDialog(QtWidgets.QDialog):
def parse(self):
return self._options.copy()
class SeparatorWidget(QtWidgets.QFrame):
"""Prepared widget that can be used as separator with predefined color.
Args:
size (int): Size of separator (width or height).
orientation (Qt.Horizontal|Qt.Vertical): Orintation of widget.
parent (QtWidgets.QWidget): Parent widget.
"""
def __init__(self, size=2, orientation=QtCore.Qt.Horizontal, parent=None):
super(SeparatorWidget, self).__init__(parent)
self.setObjectName("Separator")
maximum_width = self.maximumWidth()
maximum_height = self.maximumHeight()
self._size = None
self._orientation = orientation
self._maximum_width = maximum_width
self._maximum_height = maximum_height
self.set_size(size)
def set_size(self, size):
if size == self._size:
return
if self._orientation == QtCore.Qt.Vertical:
self.setMinimumWidth(size)
self.setMaximumWidth(size)
else:
self.setMinimumHeight(size)
self.setMaximumHeight(size)
self._size = size
def set_orientation(self, orientation):
if self._orientation == orientation:
return
# Reset min/max sizes in opossite direction
if self._orientation == QtCore.Qt.Vertical:
self.setMinimumHeight(0)
self.setMaximumHeight(self._maximum_height)
else:
self.setMinimumWidth(0)
self.setMaximumWidth(self._maximum_width)
self._orientation = orientation
size = self._size
self._size = None
self.set_size(size)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.14.4"
__version__ = "3.14.5"

View file

@ -47,10 +47,14 @@ Context discovers creator and publish plugins. Trigger collections of existing i
Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instances but these methods are not meant to be called directly out of the creator. The reason is that it is the creator's responsibility to remove metadata or decide if it should remove the instance.
#### Required functions in host implementation
Host implementation **must** implement **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instance but are needed for Creating and publishing process. Right now only data about enabled/disabled optional publish plugins is stored there. When data is not stored and loaded properly, reset of publishing will cause that they will be set to default value. Context data also parsed to json string similarly as instance data.
During reset are re-cached Creator plugins, re-collected instances, refreshed host context and more. Object of `CreateContext` supply shared data during the reset. They can be used by creators to share same data needed during collection phase or during creation for autocreators.
There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return a string shown in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/<b>{asset name}</b>/{task name}"`.
#### Required functions in host implementation
It is recommended to use `HostBase` class (`from openpype.host import HostBase`) as base for host implementation with combination of `IPublishHost` interface (`from openpype.host import IPublishHost`). These abstract classes should guide you to fill missing attributes and methods.
To sum them and in case host implementation is inheriting `HostBase` the implementation **must** implement **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instance but are needed for Creating and publishing process. Right now only data about enabled/disabled optional publish plugins is stored there. When data is not stored and loaded properly, reset of publishing will cause that they will be set to default value. Context data also parsed to json string similarly as instance data.
There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return a string shown in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/<b>{asset name}</b>/{task name}"` (this is default implementation in `HostBase`).
Another optional function is **get_current_context**. This function is handy in hosts where it is possible to open multiple workfiles in one process so using global context variables is not relevant because artists can switch between opened workfiles without being acknowledged. When a function is not implemented or won't return the right keys the global context is used.
```json
@ -68,6 +72,9 @@ Main responsibility of create plugin is to create, update, collect and remove in
#### *BaseCreator*
Base implementation of creator plugin. It is not recommended to use this class as base for production plugins but rather use one of **HiddenCreator**, **AutoCreator** and **Creator** variants.
**Access to shared data**
Functions to work with "Collection shared data" can be used during reset phase of `CreateContext`. Creators can cache there data that are common for them. For example list of nodes in scene. Methods are implemented on `CreateContext` but their usage is primarily for Create plugins as nothing else should use it. Each creator can access `collection_shared_data` attribute which is a dictionary where shared data can be stored.
**Abstractions**
- **`family`** (class attr) - Tells what kind of instance will be created.
```python