diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 97b3175c57..bb199a61f7 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -6,8 +6,8 @@ import attr import pyblish.api from openpype.settings import get_project_settings -from openpype.lib import abstract_collect_render -from openpype.lib.abstract_collect_render import RenderInstance +from openpype.pipeline import publish +from openpype.pipeline.publish import RenderInstance from openpype.hosts.aftereffects.api import get_stub @@ -25,7 +25,7 @@ class AERenderInstance(RenderInstance): file_name = attr.ib(default=None) -class CollectAERender(abstract_collect_render.AbstractCollectRender): +class CollectAERender(publish.AbstractCollectRender): order = pyblish.api.CollectorOrder + 0.405 label = "Collect After Effects Render Layers" diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 3e9e680efd..f6b26eb3e8 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -4,11 +4,10 @@ from pathlib import Path import attr -import openpype.lib -import openpype.lib.abstract_collect_render -from openpype.lib.abstract_collect_render import RenderInstance from openpype.lib import get_formatted_current_time from openpype.pipeline import legacy_io +from openpype.pipeline import publish +from openpype.pipeline.publish import RenderInstance import openpype.hosts.harmony.api as harmony @@ -20,8 +19,7 @@ class HarmonyRenderInstance(RenderInstance): leadingZeros = attr.ib(default=3) -class CollectFarmRender(openpype.lib.abstract_collect_render. - AbstractCollectRender): +class CollectFarmRender(publish.AbstractCollectRender): """Gather all publishable renders.""" # https://docs.toonboom.com/help/harmony-17/premium/reference/node/output/write-node-image-formats.html diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 3d81f6d794..e4ff87aa0f 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -1,269 +1,33 @@ # -*- coding: utf-8 -*- -"""Collect render template. +"""Content was moved to 'openpype.pipeline.publish.abstract_collect_render'. -TODO: use @dataclass when times come. +Please change your imports as soon as possible. +File will be probably removed in OpenPype 3.14.* """ -from abc import abstractmethod -import attr -import six - -import pyblish.api - -from openpype.pipeline import legacy_io - -from .abstract_metaplugins import AbstractMetaContextPlugin +import warnings +from openpype.pipeline.publish import AbstractCollectRender, RenderInstance -@attr.s -class RenderInstance(object): - """Data collected by collectors. - - This data class later on passed to collected instances. - Those attributes are required later on. - - """ - - # metadata - version = attr.ib() # instance version - time = attr.ib() # time of instance creation (get_formatted_current_time) - source = attr.ib() # path to source scene file - label = attr.ib() # label to show in GUI - subset = attr.ib() # subset name - task = attr.ib() # task name - asset = attr.ib() # asset name (AVALON_ASSET) - attachTo = attr.ib() # subset name to attach render to - setMembers = attr.ib() # list of nodes/members producing render output - publish = attr.ib() # bool, True to publish instance - name = attr.ib() # instance name - - # format settings - resolutionWidth = attr.ib() # resolution width (1920) - resolutionHeight = attr.ib() # resolution height (1080) - pixelAspect = attr.ib() # pixel aspect (1.0) - - # time settings - frameStart = attr.ib() # start frame - frameEnd = attr.ib() # start end - frameStep = attr.ib() # frame step - - handleStart = attr.ib(default=None) # start frame - handleEnd = attr.ib(default=None) # start frame - - # for software (like Harmony) where frame range cannot be set by DB - # handles need to be propagated if exist - ignoreFrameHandleCheck = attr.ib(default=False) - - # -------------------- - # With default values - # metadata - renderer = attr.ib(default="") # renderer - can be used in Deadline - review = attr.ib(default=False) # generate review from instance (bool) - priority = attr.ib(default=50) # job priority on farm - - family = attr.ib(default="renderlayer") - families = attr.ib(default=["renderlayer"]) # list of families - - # format settings - multipartExr = attr.ib(default=False) # flag for multipart exrs - convertToScanline = attr.ib(default=False) # flag for exr conversion - - tileRendering = attr.ib(default=False) # bool: treat render as tiles - tilesX = attr.ib(default=0) # number of tiles in X - tilesY = attr.ib(default=0) # number of tiles in Y - - # submit_publish_job - toBeRenderedOn = attr.ib(default=None) - deadlineSubmissionJob = attr.ib(default=None) - anatomyData = attr.ib(default=None) - outputDir = attr.ib(default=None) - context = attr.ib(default=None) - - @frameStart.validator - def check_frame_start(self, _, value): - """Validate if frame start is not larger then end.""" - if value > self.frameEnd: - raise ValueError("frameStart must be smaller " - "or equal then frameEnd") - - @frameEnd.validator - def check_frame_end(self, _, value): - """Validate if frame end is not less then start.""" - if value < self.frameStart: - raise ValueError("frameEnd must be smaller " - "or equal then frameStart") - - @tilesX.validator - def check_tiles_x(self, _, value): - """Validate if tile x isn't less then 1.""" - if not self.tileRendering: - return - if value < 1: - raise ValueError("tile X size cannot be less then 1") - - if value == 1 and self.tilesY == 1: - raise ValueError("both tiles X a Y sizes are set to 1") - - @tilesY.validator - def check_tiles_y(self, _, value): - """Validate if tile y isn't less then 1.""" - if not self.tileRendering: - return - if value < 1: - raise ValueError("tile Y size cannot be less then 1") - - if value == 1 and self.tilesX == 1: - raise ValueError("both tiles X a Y sizes are set to 1") +class CollectRenderDeprecated(DeprecationWarning): + pass -@six.add_metaclass(AbstractMetaContextPlugin) -class AbstractCollectRender(pyblish.api.ContextPlugin): - """Gather all publishable render layers from renderSetup.""" +warnings.simplefilter("always", CollectRenderDeprecated) +warnings.warn( + ( + "Content of 'abstract_collect_render' was moved." + "\nUsing deprecated source of 'abstract_collect_render'. Content was" + " move to 'openpype.pipeline.publish.abstract_collect_render'." + " Please change your imports as soon as possible." + ), + category=CollectRenderDeprecated, + stacklevel=4 +) - order = pyblish.api.CollectorOrder + 0.01 - label = "Collect Render" - sync_workfile_version = False - def __init__(self, *args, **kwargs): - """Constructor.""" - super(AbstractCollectRender, self).__init__(*args, **kwargs) - self._file_path = None - self._asset = legacy_io.Session["AVALON_ASSET"] - self._context = None - - def process(self, context): - """Entry point to collector.""" - self._context = context - for instance in context: - # make sure workfile instance publishing is enabled - try: - if "workfile" in instance.data["families"]: - instance.data["publish"] = True - # TODO merge renderFarm and render.farm - if ("renderFarm" in instance.data["families"] or - "render.farm" in instance.data["families"]): - instance.data["remove"] = True - except KeyError: - # be tolerant if 'families' is missing. - pass - - self._file_path = context.data["currentFile"].replace("\\", "/") - - render_instances = self.get_instances(context) - for render_instance in render_instances: - exp_files = self.get_expected_files(render_instance) - assert exp_files, "no file names were generated, this is bug" - - # if we want to attach render to subset, check if we have AOV's - # in expectedFiles. If so, raise error as we cannot attach AOV - # (considered to be subset on its own) to another subset - if render_instance.attachTo: - assert isinstance(exp_files, list), ( - "attaching multiple AOVs or renderable cameras to " - "subset is not supported" - ) - - frame_start_render = int(render_instance.frameStart) - frame_end_render = int(render_instance.frameEnd) - if (render_instance.ignoreFrameHandleCheck or - int(context.data['frameStartHandle']) == frame_start_render - and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501 - - handle_start = context.data['handleStart'] - handle_end = context.data['handleEnd'] - frame_start = context.data['frameStart'] - frame_end = context.data['frameEnd'] - frame_start_handle = context.data['frameStartHandle'] - frame_end_handle = context.data['frameEndHandle'] - else: - handle_start = 0 - handle_end = 0 - frame_start = frame_start_render - frame_end = frame_end_render - frame_start_handle = frame_start_render - frame_end_handle = frame_end_render - - data = { - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStart": frame_start, - "frameEnd": frame_end, - "frameStartHandle": frame_start_handle, - "frameEndHandle": frame_end_handle, - "byFrameStep": int(render_instance.frameStep), - - "author": context.data["user"], - # Add source to allow tracing back to the scene from - # which was submitted originally - "expectedFiles": exp_files, - } - if self.sync_workfile_version: - data["version"] = context.data["version"] - - # add additional data - data = self.add_additional_data(data) - render_instance_dict = attr.asdict(render_instance) - - instance = context.create_instance(render_instance.name) - instance.data["label"] = render_instance.label - instance.data.update(render_instance_dict) - instance.data.update(data) - - self.post_collecting_action() - - @abstractmethod - def get_instances(self, context): - """Get all renderable instances and their data. - - Args: - context (pyblish.api.Context): Context object. - - Returns: - list of :class:`RenderInstance`: All collected renderable instances - (like render layers, write nodes, etc.) - - """ - pass - - @abstractmethod - def get_expected_files(self, render_instance): - """Get list of expected files. - - Returns: - list: expected files. This can be either simple list of files with - their paths, or list of dictionaries, where key is name of AOV - for example and value is list of files for that AOV. - - Example:: - - ['/path/to/file.001.exr', '/path/to/file.002.exr'] - - or as dictionary: - - [ - { - "beauty": ['/path/to/beauty.001.exr', ...], - "mask": ['/path/to/mask.001.exr'] - } - ] - - """ - pass - - def add_additional_data(self, data): - """Add additional data to collected instance. - - This can be overridden by host implementation to add custom - additional data. - - """ - return data - - def post_collecting_action(self): - """Execute some code after collection is done. - - This is useful for example for restoring current render layer. - - """ - pass +__all__ = ( + "AbstractCollectRender", + "RenderInstance" +) diff --git a/openpype/lib/abstract_expected_files.py b/openpype/lib/abstract_expected_files.py index f9f3c17ef5..f24d844fe5 100644 --- a/openpype/lib/abstract_expected_files.py +++ b/openpype/lib/abstract_expected_files.py @@ -1,53 +1,32 @@ # -*- coding: utf-8 -*- -"""Abstract ExpectedFile class definition.""" -from abc import ABCMeta, abstractmethod -import six +"""Content was moved to 'openpype.pipeline.publish.abstract_expected_files'. + +Please change your imports as soon as possible. + +File will be probably removed in OpenPype 3.14.* +""" + +import warnings +from openpype.pipeline.publish import ExpectedFiles -@six.add_metaclass(ABCMeta) -class ExpectedFiles: - """Class grouping functionality for all supported renderers. - - Attributes: - multipart (bool): Flag if multipart exrs are used. - - """ - - multipart = False - - @abstractmethod - def get(self, render_instance): - """Get expected files for given renderer and render layer. - - This method should return dictionary of all files we are expecting - to be rendered from the host. Usually `render_instance` corresponds - to *render layer*. Result can be either flat list with the file - paths or it can be list of dictionaries. Each key corresponds to - for example AOV name or channel, etc. - - Example:: - - ['/path/to/file.001.exr', '/path/to/file.002.exr'] - - or as dictionary: - - [ - { - "beauty": ['/path/to/beauty.001.exr', ...], - "mask": ['/path/to/mask.001.exr'] - } - ] +class ExpectedFilesDeprecated(DeprecationWarning): + pass - Args: - render_instance (:class:`RenderInstance`): Data passed from - collector to determine files. This should be instance of - :class:`abstract_collect_render.RenderInstance` +warnings.simplefilter("always", ExpectedFilesDeprecated) +warnings.warn( + ( + "Content of 'abstract_expected_files' was moved." + "\nUsing deprecated source of 'abstract_expected_files'. Content was" + " move to 'openpype.pipeline.publish.abstract_expected_files'." + " Please change your imports as soon as possible." + ), + category=ExpectedFilesDeprecated, + stacklevel=4 +) - Returns: - list: Full paths to expected rendered files. - list of dict: Path to expected rendered files categorized by - AOVs, etc. - """ - raise NotImplementedError() +__all__ = ( + "ExpectedFiles", +) diff --git a/openpype/lib/abstract_metaplugins.py b/openpype/lib/abstract_metaplugins.py index f8163956ad..346b5d86b3 100644 --- a/openpype/lib/abstract_metaplugins.py +++ b/openpype/lib/abstract_metaplugins.py @@ -1,10 +1,35 @@ -from abc import ABCMeta -from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin +"""Content was moved to 'openpype.pipeline.publish.publish_plugins'. + +Please change your imports as soon as possible. + +File will be probably removed in OpenPype 3.14.* +""" + +import warnings +from openpype.pipeline.publish import ( + AbstractMetaInstancePlugin, + AbstractMetaContextPlugin +) -class AbstractMetaInstancePlugin(ABCMeta, MetaPlugin): +class MetaPluginsDeprecated(DeprecationWarning): pass -class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin): - pass +warnings.simplefilter("always", MetaPluginsDeprecated) +warnings.warn( + ( + "Content of 'abstract_metaplugins' was moved." + "\nUsing deprecated source of 'abstract_metaplugins'. Content was" + " moved to 'openpype.pipeline.publish.publish_plugins'." + " Please change your imports as soon as possible." + ), + category=MetaPluginsDeprecated, + stacklevel=4 +) + + +__all__ = ( + "AbstractMetaInstancePlugin", + "AbstractMetaContextPlugin", +) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 82f77ef9e3..f46197e15f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -665,7 +665,11 @@ class ApplicationExecutable: if os.path.exists(plist_filepath): import plistlib - parsed_plist = plistlib.readPlist(plist_filepath) + if hasattr(plistlib, "load"): + with open(plist_filepath, "rb") as stream: + parsed_plist = plistlib.load(stream) + else: + parsed_plist = plistlib.readPlist(plist_filepath) executable_filename = parsed_plist.get("CFBundleExecutable") if executable_filename: diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 22902d79ea..3f54273a56 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -15,7 +15,7 @@ import attr import requests import pyblish.api -from openpype.lib.abstract_metaplugins import AbstractMetaInstancePlugin +from openpype.pipeline.publish import AbstractMetaInstancePlugin def requests_post(*args, **kwargs): diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 7758a660d3..aecdb04635 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -496,6 +496,20 @@ class CreatedInstance: def subset_name(self): return self._data["subset"] + @property + def label(self): + label = self._data.get("label") + if not label: + label = self.subset_name + return label + + @property + def group_label(self): + label = self._data.get("group") + if label: + return label + return self.creator.get_group_label() + @property def creator_identifier(self): return self.creator.identifier diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 4c7b9f8d94..91b9d80234 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,5 +1,4 @@ import copy -import logging from abc import ( ABCMeta, @@ -47,6 +46,9 @@ class BaseCreator: # Label shown in UI label = None + group_label = None + # Cached group label after first call 'get_group_label' + _cached_group_label = None # Variable to store logger _log = None @@ -85,11 +87,13 @@ class BaseCreator: Default implementation returns plugin's family. """ + return self.family @abstractproperty def family(self): """Family that plugin represents.""" + pass @property @@ -98,8 +102,35 @@ class BaseCreator: return self.create_context.project_name + def get_group_label(self): + """Group label under which are instances grouped in UI. + + Default implementation use attributes in this order: + - 'group_label' -> 'label' -> 'identifier' + Keep in mind that 'identifier' use 'family' by default. + + Returns: + str: Group label that can be used for grouping of instances in UI. + Group label can be overriden by instance itself. + """ + + if self._cached_group_label is None: + label = self.identifier + if self.group_label: + label = self.group_label + elif self.label: + label = self.label + self._cached_group_label = label + return self._cached_group_label + @property def log(self): + """Logger of the plugin. + + Returns: + logging.Logger: Logger with name of the plugin. + """ + if self._log is None: from openpype.api import Logger @@ -107,10 +138,30 @@ class BaseCreator: return self._log def _add_instance_to_context(self, instance): - """Helper method to ad d""" + """Helper method to add instance to create context. + + Instances should be stored to DCC workfile metadata to be able reload + them and also stored to CreateContext in which is creator plugin + existing at the moment to be able use it without refresh of + CreateContext. + + Args: + instance (CreatedInstance): New created instance. + """ + self.create_context.creator_adds_instance(instance) def _remove_instance_from_context(self, instance): + """Helper method to remove instance from create context. + + Instances must be removed from DCC workfile metadat aand from create + context in which plugin is existing at the moment of removement to + propagate the change without restarting create context. + + Args: + instance (CreatedInstance): Instance which should be removed. + """ + self.create_context.creator_removed_instance(instance) @abstractmethod @@ -121,6 +172,7 @@ class BaseCreator: - must expect all data that were passed to init in previous implementation """ + pass @abstractmethod @@ -147,6 +199,7 @@ class BaseCreator: self._add_instance_to_context(instance) ``` """ + pass @abstractmethod @@ -154,9 +207,10 @@ class BaseCreator: """Store changes of existing instances so they can be recollected. Args: - update_list(list): Gets list of tuples. Each item + update_list(List[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ + pass @abstractmethod @@ -167,9 +221,10 @@ class BaseCreator: 'True' if did so. Args: - instance(list): Instance objects which should be + instance(List[CreatedInstance]): Instance objects which should be removed. """ + pass def get_icon(self): @@ -177,6 +232,7 @@ class BaseCreator: Can return path to image file or awesome icon name. """ + return self.icon def get_dynamic_data( @@ -187,6 +243,7 @@ class BaseCreator: These may be get dynamically created based on current context of workfile. """ + return {} def get_subset_name( @@ -211,6 +268,7 @@ class BaseCreator: project_name(str): Project name. host_name(str): Which host creates subset. """ + dynamic_data = self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name ) @@ -237,9 +295,10 @@ class BaseCreator: keys/values when plugin attributes change. Returns: - list: Attribute definitions that can be tweaked for + List[AbtractAttrDef]: Attribute definitions that can be tweaked for created instance. """ + return self.instance_attr_defs @@ -297,6 +356,7 @@ class Creator(BaseCreator): Returns: str: Short description of family. """ + return self.description def get_detail_description(self): @@ -307,6 +367,7 @@ class Creator(BaseCreator): Returns: str: Detailed description of family for artist. """ + return self.detailed_description def get_default_variants(self): @@ -318,8 +379,9 @@ class Creator(BaseCreator): By default returns `default_variants` value. Returns: - list: Whisper variants for user input. + List[str]: Whisper variants for user input. """ + return copy.deepcopy(self.default_variants) def get_default_variant(self): @@ -338,11 +400,13 @@ class Creator(BaseCreator): """Plugin attribute definitions needed for creation. Attribute definitions of plugin that define how creation will work. Values of these definitions are passed to `create` method. - NOTE: - Convert method should be implemented which should care about updating - keys/values when plugin attributes change. + + Note: + Convert method should be implemented which should care about + updating keys/values when plugin attributes change. + Returns: - list: Attribute definitions that can be tweaked for + List[AbtractAttrDef]: Attribute definitions that can be tweaked for created instance. """ return self.pre_create_attr_defs diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index af5d7c4a91..aa7fe0bdbf 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -1,4 +1,7 @@ from .publish_plugins import ( + AbstractMetaInstancePlugin, + AbstractMetaContextPlugin, + PublishValidationError, PublishXmlValidationError, KnownPublishError, @@ -13,8 +16,17 @@ from .lib import ( load_help_content_from_filepath, ) +from .abstract_expected_files import ExpectedFiles +from .abstract_collect_render import ( + RenderInstance, + AbstractCollectRender, +) + __all__ = ( + "AbstractMetaInstancePlugin", + "AbstractMetaContextPlugin", + "PublishValidationError", "PublishXmlValidationError", "KnownPublishError", @@ -25,4 +37,9 @@ __all__ = ( "publish_plugins_discover", "load_help_content_from_plugin", "load_help_content_from_filepath", + + "ExpectedFiles", + + "RenderInstance", + "AbstractCollectRender", ) diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py new file mode 100644 index 0000000000..2e537227c3 --- /dev/null +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +"""Collect render template. + +TODO: use @dataclass when times come. + +""" +from abc import abstractmethod + +import attr +import six + +import pyblish.api + +from openpype.pipeline import legacy_io +from .publish_plugins import AbstractMetaContextPlugin + + +@attr.s +class RenderInstance(object): + """Data collected by collectors. + + This data class later on passed to collected instances. + Those attributes are required later on. + + """ + + # metadata + version = attr.ib() # instance version + time = attr.ib() # time of instance creation (get_formatted_current_time) + source = attr.ib() # path to source scene file + label = attr.ib() # label to show in GUI + subset = attr.ib() # subset name + task = attr.ib() # task name + asset = attr.ib() # asset name (AVALON_ASSET) + attachTo = attr.ib() # subset name to attach render to + setMembers = attr.ib() # list of nodes/members producing render output + publish = attr.ib() # bool, True to publish instance + name = attr.ib() # instance name + + # format settings + resolutionWidth = attr.ib() # resolution width (1920) + resolutionHeight = attr.ib() # resolution height (1080) + pixelAspect = attr.ib() # pixel aspect (1.0) + + # time settings + frameStart = attr.ib() # start frame + frameEnd = attr.ib() # start end + frameStep = attr.ib() # frame step + + handleStart = attr.ib(default=None) # start frame + handleEnd = attr.ib(default=None) # start frame + + # for software (like Harmony) where frame range cannot be set by DB + # handles need to be propagated if exist + ignoreFrameHandleCheck = attr.ib(default=False) + + # -------------------- + # With default values + # metadata + renderer = attr.ib(default="") # renderer - can be used in Deadline + review = attr.ib(default=False) # generate review from instance (bool) + priority = attr.ib(default=50) # job priority on farm + + family = attr.ib(default="renderlayer") + families = attr.ib(default=["renderlayer"]) # list of families + + # format settings + multipartExr = attr.ib(default=False) # flag for multipart exrs + convertToScanline = attr.ib(default=False) # flag for exr conversion + + tileRendering = attr.ib(default=False) # bool: treat render as tiles + tilesX = attr.ib(default=0) # number of tiles in X + tilesY = attr.ib(default=0) # number of tiles in Y + + # submit_publish_job + toBeRenderedOn = attr.ib(default=None) + deadlineSubmissionJob = attr.ib(default=None) + anatomyData = attr.ib(default=None) + outputDir = attr.ib(default=None) + context = attr.ib(default=None) + + @frameStart.validator + def check_frame_start(self, _, value): + """Validate if frame start is not larger then end.""" + if value > self.frameEnd: + raise ValueError("frameStart must be smaller " + "or equal then frameEnd") + + @frameEnd.validator + def check_frame_end(self, _, value): + """Validate if frame end is not less then start.""" + if value < self.frameStart: + raise ValueError("frameEnd must be smaller " + "or equal then frameStart") + + @tilesX.validator + def check_tiles_x(self, _, value): + """Validate if tile x isn't less then 1.""" + if not self.tileRendering: + return + if value < 1: + raise ValueError("tile X size cannot be less then 1") + + if value == 1 and self.tilesY == 1: + raise ValueError("both tiles X a Y sizes are set to 1") + + @tilesY.validator + def check_tiles_y(self, _, value): + """Validate if tile y isn't less then 1.""" + if not self.tileRendering: + return + if value < 1: + raise ValueError("tile Y size cannot be less then 1") + + if value == 1 and self.tilesX == 1: + raise ValueError("both tiles X a Y sizes are set to 1") + + +@six.add_metaclass(AbstractMetaContextPlugin) +class AbstractCollectRender(pyblish.api.ContextPlugin): + """Gather all publishable render layers from renderSetup.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect Render" + sync_workfile_version = False + + def __init__(self, *args, **kwargs): + """Constructor.""" + super(AbstractCollectRender, self).__init__(*args, **kwargs) + self._file_path = None + self._asset = legacy_io.Session["AVALON_ASSET"] + self._context = None + + def process(self, context): + """Entry point to collector.""" + self._context = context + for instance in context: + # make sure workfile instance publishing is enabled + try: + if "workfile" in instance.data["families"]: + instance.data["publish"] = True + # TODO merge renderFarm and render.farm + if ("renderFarm" in instance.data["families"] or + "render.farm" in instance.data["families"]): + instance.data["remove"] = True + except KeyError: + # be tolerant if 'families' is missing. + pass + + self._file_path = context.data["currentFile"].replace("\\", "/") + + render_instances = self.get_instances(context) + for render_instance in render_instances: + exp_files = self.get_expected_files(render_instance) + assert exp_files, "no file names were generated, this is bug" + + # if we want to attach render to subset, check if we have AOV's + # in expectedFiles. If so, raise error as we cannot attach AOV + # (considered to be subset on its own) to another subset + if render_instance.attachTo: + assert isinstance(exp_files, list), ( + "attaching multiple AOVs or renderable cameras to " + "subset is not supported" + ) + + frame_start_render = int(render_instance.frameStart) + frame_end_render = int(render_instance.frameEnd) + if (render_instance.ignoreFrameHandleCheck or + int(context.data['frameStartHandle']) == frame_start_render + and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501 + + handle_start = context.data['handleStart'] + handle_end = context.data['handleEnd'] + frame_start = context.data['frameStart'] + frame_end = context.data['frameEnd'] + frame_start_handle = context.data['frameStartHandle'] + frame_end_handle = context.data['frameEndHandle'] + else: + handle_start = 0 + handle_end = 0 + frame_start = frame_start_render + frame_end = frame_end_render + frame_start_handle = frame_start_render + frame_end_handle = frame_end_render + + data = { + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": frame_start, + "frameEnd": frame_end, + "frameStartHandle": frame_start_handle, + "frameEndHandle": frame_end_handle, + "byFrameStep": int(render_instance.frameStep), + + "author": context.data["user"], + # Add source to allow tracing back to the scene from + # which was submitted originally + "expectedFiles": exp_files, + } + if self.sync_workfile_version: + data["version"] = context.data["version"] + + # add additional data + data = self.add_additional_data(data) + render_instance_dict = attr.asdict(render_instance) + + instance = context.create_instance(render_instance.name) + instance.data["label"] = render_instance.label + instance.data.update(render_instance_dict) + instance.data.update(data) + + self.post_collecting_action() + + @abstractmethod + def get_instances(self, context): + """Get all renderable instances and their data. + + Args: + context (pyblish.api.Context): Context object. + + Returns: + list of :class:`RenderInstance`: All collected renderable instances + (like render layers, write nodes, etc.) + + """ + pass + + @abstractmethod + def get_expected_files(self, render_instance): + """Get list of expected files. + + Returns: + list: expected files. This can be either simple list of files with + their paths, or list of dictionaries, where key is name of AOV + for example and value is list of files for that AOV. + + Example:: + + ['/path/to/file.001.exr', '/path/to/file.002.exr'] + + or as dictionary: + + [ + { + "beauty": ['/path/to/beauty.001.exr', ...], + "mask": ['/path/to/mask.001.exr'] + } + ] + + """ + pass + + def add_additional_data(self, data): + """Add additional data to collected instance. + + This can be overridden by host implementation to add custom + additional data. + + """ + return data + + def post_collecting_action(self): + """Execute some code after collection is done. + + This is useful for example for restoring current render layer. + + """ + pass diff --git a/openpype/pipeline/publish/abstract_expected_files.py b/openpype/pipeline/publish/abstract_expected_files.py new file mode 100644 index 0000000000..f9f3c17ef5 --- /dev/null +++ b/openpype/pipeline/publish/abstract_expected_files.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Abstract ExpectedFile class definition.""" +from abc import ABCMeta, abstractmethod +import six + + +@six.add_metaclass(ABCMeta) +class ExpectedFiles: + """Class grouping functionality for all supported renderers. + + Attributes: + multipart (bool): Flag if multipart exrs are used. + + """ + + multipart = False + + @abstractmethod + def get(self, render_instance): + """Get expected files for given renderer and render layer. + + This method should return dictionary of all files we are expecting + to be rendered from the host. Usually `render_instance` corresponds + to *render layer*. Result can be either flat list with the file + paths or it can be list of dictionaries. Each key corresponds to + for example AOV name or channel, etc. + + Example:: + + ['/path/to/file.001.exr', '/path/to/file.002.exr'] + + or as dictionary: + + [ + { + "beauty": ['/path/to/beauty.001.exr', ...], + "mask": ['/path/to/mask.001.exr'] + } + ] + + + Args: + render_instance (:class:`RenderInstance`): Data passed from + collector to determine files. This should be instance of + :class:`abstract_collect_render.RenderInstance` + + Returns: + list: Full paths to expected rendered files. + list of dict: Path to expected rendered files categorized by + AOVs, etc. + + """ + raise NotImplementedError() diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 2402a005c2..71a2c675b6 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,7 +1,17 @@ +from abc import ABCMeta +from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin from openpype.lib import BoolDef from .lib import load_help_content_from_plugin +class AbstractMetaInstancePlugin(ABCMeta, MetaPlugin): + pass + + +class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin): + pass + + class PublishValidationError(Exception): """Validation error happened during publishing. @@ -16,6 +26,7 @@ class PublishValidationError(Exception): description(str): Detailed description of an error. It is possible to use Markdown syntax. """ + def __init__(self, message, title=None, description=None, detail=None): self.message = message self.title = title or "< Missing title >" @@ -49,6 +60,7 @@ class KnownPublishError(Exception): Message will be shown in UI for artist. """ + pass @@ -92,6 +104,7 @@ class OpenPypePyblishPluginMixin: Returns: list: Attribute definitions for plugin. """ + return [] @classmethod @@ -116,6 +129,7 @@ class OpenPypePyblishPluginMixin: Args: data(dict): Data from instance or context. """ + return ( data .get("publish_attributes", {}) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6e5fee1fe..1b6e2a1d61 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -447,7 +447,22 @@ class ExtractReview(pyblish.api.InstancePlugin): input_is_sequence = self.input_is_sequence(repre) input_allow_bg = False + first_sequence_frame = None if input_is_sequence and repre["files"]: + # Calculate first frame that should be used + cols, _ = clique.assemble(repre["files"]) + input_frames = list(sorted(cols[0].indexes)) + first_sequence_frame = input_frames[0] + # WARNING: This is an issue as we don't know if first frame + # is with or without handles! + # - handle start is added but how do not know if we should + output_duration = (output_frame_end - output_frame_start) + 1 + if ( + without_handles + and len(input_frames) - handle_start >= output_duration + ): + first_sequence_frame += handle_start + ext = os.path.splitext(repre["files"][0])[1].replace(".", "") if ext in self.alpha_exts: input_allow_bg = True @@ -467,6 +482,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "resolution_height": instance.data.get("resolutionHeight"), "origin_repre": repre, "input_is_sequence": input_is_sequence, + "first_sequence_frame": first_sequence_frame, "input_allow_bg": input_allow_bg, "with_audio": with_audio, "without_handles": without_handles, @@ -545,9 +561,9 @@ class ExtractReview(pyblish.api.InstancePlugin): if temp_data["input_is_sequence"]: # Set start frame of input sequence (just frame in filename) # - definition of input filepath - ffmpeg_input_args.append( - "-start_number {}".format(temp_data["output_frame_start"]) - ) + ffmpeg_input_args.extend([ + "-start_number", str(temp_data["first_sequence_frame"]) + ]) # TODO add fps mapping `{fps: fraction}` ? # - e.g.: { diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 086cd5c59c..b6fcee7edb 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -303,13 +303,14 @@ class InstanceCardWidget(CardWidget): self._last_variant = variant self._last_subset_name = subset_name # Make `variant` bold - found_parts = set(re.findall(variant, subset_name, re.IGNORECASE)) + label = self.instance.label + found_parts = set(re.findall(variant, label, re.IGNORECASE)) if found_parts: for part in found_parts: replacement = "{}".format(part) - subset_name = subset_name.replace(part, replacement) + label = label.replace(part, replacement) - self._label_widget.setText(subset_name) + self._label_widget.setText(label) # HTML text will cause that label start catch mouse clicks # - disabling with changing interaction flag self._label_widget.setTextInteractionFlags( @@ -435,7 +436,7 @@ class InstanceCardView(AbstractInstanceView): instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) for instance in self.controller.instances: - group_name = instance.creator_label + group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( instance.creator_identifier diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index 6bddaf66c8..3476ee487e 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -113,7 +113,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self.instance = instance - subset_name_label = QtWidgets.QLabel(instance["subset"], self) + subset_name_label = QtWidgets.QLabel(instance.label, self) subset_name_label.setObjectName("ListViewSubsetName") active_checkbox = NiceCheckbox(parent=self) @@ -132,7 +132,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_checkbox.stateChanged.connect(self._on_active_change) - self._subset_name_label = subset_name_label + self._instance_label_widget = subset_name_label self._active_checkbox = active_checkbox self._has_valid_context = None @@ -146,8 +146,8 @@ class InstanceListItemWidget(QtWidgets.QWidget): state = "" if not valid: state = "invalid" - self._subset_name_label.setProperty("state", state) - self._subset_name_label.style().polish(self._subset_name_label) + self._instance_label_widget.setProperty("state", state) + self._instance_label_widget.style().polish(self._instance_label_widget) def is_active(self): """Instance is activated.""" @@ -176,9 +176,9 @@ class InstanceListItemWidget(QtWidgets.QWidget): def update_instance_values(self): """Update instance data propagated to widgets.""" # Check subset name - subset_name = self.instance["subset"] - if subset_name != self._subset_name_label.text(): - self._subset_name_label.setText(subset_name) + label = self.instance.label + if label != self._instance_label_widget.text(): + self._instance_label_widget.setText(label) # Check active state self.set_active(self.instance["active"]) # Check valid states @@ -519,7 +519,7 @@ class InstanceListView(AbstractInstanceView): instances_by_group_name = collections.defaultdict(list) group_names = set() for instance in self.controller.instances: - group_label = instance.creator_label + group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 7096b9fb50..5a5f8c4c37 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1225,6 +1225,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): different creators. If creator have same (similar) definitions their widgets are merged into one (different label does not count). """ + def __init__(self, controller, parent): super(CreatorAttrsWidget, self).__init__(parent) @@ -1275,6 +1276,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): content_layout = QtWidgets.QGridLayout(content_widget) content_layout.setColumnStretch(0, 0) content_layout.setColumnStretch(1, 1) + content_layout.setAlignment(QtCore.Qt.AlignTop) row = 0 for attr_def, attr_instances, values in result: