Merge remote-tracking branch 'origin/feature/OP-2766_PS-to-new-publisher' into feature/OP-2766_PS-to-new-publisher

This commit is contained in:
Petr Kalis 2022-03-23 12:30:03 +01:00
commit a684f6cd4e
14 changed files with 724 additions and 111 deletions

View file

@ -181,10 +181,10 @@ def remove_instance(instance):
stub.remove_instance(inst_id)
if instance.get("members"):
item = stub.get_item(instance["members"][0])
item = stub.get_layer(instance["members"][0])
if item:
stub.rename_item(item.id,
item.name.replace(stub.PUBLISH_ICON, ''))
stub.rename_layer(item.id,
item.name.replace(stub.PUBLISH_ICON, ''))
def _get_stub():

View file

@ -3,8 +3,7 @@ from openpype.hosts.photoshop import api
from openpype.pipeline import (
Creator,
CreatedInstance,
lib,
CreatorError
lib
)
@ -30,65 +29,53 @@ class ImageCreator(Creator):
)
self._add_instance_to_context(instance)
def create(self, subset_name, data, pre_create_data):
groups = []
layers = []
create_group = False
def create(self, subset_name_from_ui, data, pre_create_data):
groups_to_create = []
top_layers_to_wrap = []
create_empty_group = False
stub = api.stub() # only after PS is up
multiple_instances = pre_create_data.get("create_multiple")
selection = stub.get_selected_layers()
top_level_selected_items = stub.get_selected_layers()
if pre_create_data.get("use_selection"):
if len(selection) > 1:
if multiple_instances:
for item in selection:
if item.group:
groups.append(item)
else:
layers.append(item)
only_single_item_selected = len(top_level_selected_items) == 1
for selected_item in top_level_selected_items:
if only_single_item_selected or pre_create_data.get("create_multiple"):
if selected_item.group:
groups_to_create.append(selected_item)
else:
top_layers_to_wrap.append(selected_item)
else:
group = stub.group_selected_layers(subset_name)
groups.append(group)
elif len(selection) == 1:
# One selected item. Use group if its a LayerSet (group), else
# create a new group.
selected_item = selection[0]
if selected_item.group:
groups.append(selected_item)
else:
layers.append(selected_item)
elif len(selection) == 0:
# No selection creates an empty group.
create_group = True
else:
group = stub.create_group(subset_name)
groups.append(group)
group = stub.group_selected_layers(subset_name_from_ui)
groups_to_create.append(group)
if create_group:
group = stub.create_group(subset_name)
groups.append(group)
if not groups_to_create and not top_layers_to_wrap:
group = stub.create_group(subset_name_from_ui)
groups_to_create.append(group)
for layer in layers:
# wrap each top level layer into separate new group
for layer in top_layers_to_wrap:
stub.select_layers([layer])
group = stub.group_selected_layers(layer.name)
groups.append(group)
groups_to_create.append(group)
for group in groups:
long_names = []
group.name = self._clean_highlights(stub, group.name)
creating_multiple_groups = len(groups_to_create) > 1
for group in groups_to_create:
subset_name = subset_name_from_ui # reset to name from creator UI
layer_names_in_hierarchy = []
created_group_name = self._clean_highlights(stub, group.name)
if len(groups) > 1:
if creating_multiple_groups:
# concatenate with layer name to differentiate subsets
subset_name += group.name.title().replace(" ", "")
if group.long_name:
for directory in group.long_name[::-1]:
name = self._clean_highlights(stub, directory)
long_names.append(name)
layer_names_in_hierarchy.append(name)
data.update({"subset": subset_name})
data.update({"layer": group})
data.update({"members": [str(group.id)]})
data.update({"long_name": "_".join(long_names)})
data.update({"long_name": "_".join(layer_names_in_hierarchy)})
new_instance = CreatedInstance(self.family, subset_name, data,
self)
@ -97,16 +84,16 @@ class ImageCreator(Creator):
new_instance.data_to_store())
self._add_instance_to_context(new_instance)
# reusing existing group, need to rename afterwards
if not create_group:
stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name)
if not create_empty_group:
stub.rename_layer(group.id, stub.PUBLISH_ICON + created_group_name)
def update_instances(self, update_list):
self.log.debug("update_list:: {}".format(update_list))
created_inst, changes = update_list[0]
if created_inst.get("layer"):
created_inst.pop("layer") # not storing PSItem layer to metadata
api.stub().imprint(created_inst.get("instance_id"),
created_inst.data_to_store())
for created_inst, _changes in update_list:
if created_inst.get("layer"):
created_inst.pop("layer") # not storing PSItem layer to metadata
api.stub().imprint(created_inst.get("instance_id"),
created_inst.data_to_store())
def remove_instances(self, instances):
for instance in instances:
@ -120,7 +107,7 @@ class ImageCreator(Creator):
def get_pre_create_attr_defs(self):
output = [
lib.BoolDef("use_selection", default=True, label="Use selection"),
lib.BoolDef("use_selection", default=True, label="Create only for selected"),
lib.BoolDef("create_multiple",
default=True,
label="Create separate instance for each selected")

View file

@ -61,7 +61,7 @@ class ImageLoader(photoshop.PhotoshopLoader):
)
stub.imprint(
layer, {"representation": str(representation["_id"])}
layer.id, {"representation": str(representation["_id"])}
)
def remove(self, container):
@ -73,7 +73,7 @@ class ImageLoader(photoshop.PhotoshopLoader):
stub = self.get_stub()
layer = container.pop("layer")
stub.imprint(layer, {})
stub.imprint(layer.id, {})
stub.delete_layer(layer.id)
def switch(self, container, representation):

View file

@ -61,7 +61,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader):
)
stub.imprint(
layer, {"representation": str(representation["_id"])}
layer.id, {"representation": str(representation["_id"])}
)
def remove(self, container):
@ -72,7 +72,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader):
"""
stub = self.get_stub()
layer = container.pop("layer")
stub.imprint(layer, {})
stub.imprint(layer.id, {})
stub.delete_layer(layer.id)
def switch(self, container, representation):

View file

@ -1,3 +1,4 @@
import pprint
import pyblish.api
from openpype.hosts.photoshop import api as photoshop
@ -6,14 +7,14 @@ from openpype.hosts.photoshop import api as photoshop
class CollectInstances(pyblish.api.ContextPlugin):
"""Gather instances by LayerSet and file metadata
This collector takes into account assets that are associated with
an LayerSet and marked with a unique identifier;
Collects publishable instances from file metadata or enhance
already collected by creator (family == "image").
Identifier:
id (str): "pyblish.avalon.instance"
"""
label = "Instances"
label = "Collect Instances"
order = pyblish.api.CollectorOrder
hosts = ["photoshop"]
families_mapping = {
@ -21,44 +22,49 @@ class CollectInstances(pyblish.api.ContextPlugin):
}
def process(self, context):
if context.data.get("newPublishing"):
self.log.debug("Not applicable for New Publisher, skip")
return
instance_by_layer_id = {}
for instance in context:
if (
instance.data["family"] == "image" and
instance.data.get("members")):
layer_id = str(instance.data["members"][0])
instance_by_layer_id[layer_id] = instance
stub = photoshop.stub()
layers = stub.get_layers()
layer_items = stub.get_layers()
layers_meta = stub.get_layers_metadata()
instance_names = []
for layer in layers:
layer_data = stub.read(layer, layers_meta)
for layer_item in layer_items:
layer_meta_data = stub.read(layer_item, layers_meta)
# Skip layers without metadata.
if layer_data is None:
if layer_meta_data is None:
continue
# Skip containers.
if "container" in layer_data["id"]:
if "container" in layer_meta_data["id"]:
continue
# child_layers = [*layer.Layers]
# self.log.debug("child_layers {}".format(child_layers))
# if not child_layers:
# self.log.info("%s skipped, it was empty." % layer.Name)
# continue
if not layer_meta_data.get("active", True): # active might not be in legacy meta
continue
instance = context.create_instance(layer_data["subset"])
instance.data["layer"] = layer
instance.data.update(layer_data)
instance = instance_by_layer_id.get(str(layer_item.id))
if instance is None:
instance = context.create_instance(layer_meta_data["subset"])
instance.data["layer"] = layer_item
instance.data.update(layer_meta_data)
instance.data["families"] = self.families_mapping[
layer_data["family"]
layer_meta_data["family"]
]
instance.data["publish"] = layer.visible
instance_names.append(layer_data["subset"])
instance.data["publish"] = layer_item.visible
instance_names.append(layer_meta_data["subset"])
# Produce diagnostic message for any graphical
# user interface interested in visualising it.
self.log.info("Found: \"%s\" " % instance.data["name"])
self.log.info("instance: {} ".format(instance.data))
self.log.info("instance: {} ".format(
pprint.pformat(instance.data, indent=4)))
if len(instance_names) != len(set(instance_names)):
self.log.warning("Duplicate instances found. " +

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset name</title>
<description>
## Invalid subset or layer name
Subset or layer name cannot contain specific characters (spaces etc) which could cause issue when subset name is used in a published file name.
{msg}
### How to repair?
You can fix this with "repair" button on the right.
</description>
<detail>
### __Detailed Info__ (optional)
Not all characters are available in a file names on all OS. Wrong characters could be configured in Settings.
</detail>
</error>
</root>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset not unique</title>
<description>
## Non unique subset name found
Non unique subset names: '{non_unique}'
### How to repair?
Remove offending instance, rename it to have unique name. Maybe layer name wasn't used for multiple instances?
</description>
</error>
</root>

View file

@ -2,6 +2,7 @@ import re
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.photoshop import api as photoshop
@ -22,32 +23,33 @@ class ValidateNamingRepair(pyblish.api.Action):
failed.append(result["instance"])
invalid_chars, replace_char = plugin.get_replace_chars()
self.log.info("{} --- {}".format(invalid_chars, replace_char))
self.log.debug("{} --- {}".format(invalid_chars, replace_char))
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
stub = photoshop.stub()
for instance in instances:
self.log.info("validate_naming instance {}".format(instance))
metadata = stub.read(instance[0])
self.log.info("metadata instance {}".format(metadata))
layer_name = None
if metadata.get("uuid"):
layer_data = stub.get_layer(metadata["uuid"])
self.log.info("layer_data {}".format(layer_data))
if layer_data:
layer_name = re.sub(invalid_chars,
replace_char,
layer_data.name)
self.log.debug("validate_naming instance {}".format(instance))
current_layer_state = stub.get_layer(instance.data["layer"].id)
self.log.debug("current_layer_state instance {}".format(current_layer_state))
stub.rename_layer(instance.data["uuid"], layer_name)
layer_meta = stub.read(current_layer_state)
instance_id = layer_meta.get("instance_id") or layer_meta.get("uuid")
if not instance_id:
self.log.warning("Unable to repair, cannot find layer")
continue
layer_name = re.sub(invalid_chars,
replace_char,
current_layer_state.name)
stub.rename_layer(current_layer_state.id, layer_name)
subset_name = re.sub(invalid_chars, replace_char,
instance.data["name"])
instance.data["subset"])
instance[0].Name = layer_name or subset_name
metadata["subset"] = subset_name
stub.imprint(instance[0], metadata)
layer_meta["subset"] = subset_name
stub.imprint(instance_id, layer_meta)
return True
@ -72,11 +74,18 @@ class ValidateNaming(pyblish.api.InstancePlugin):
help_msg = ' Use Repair action (A) in Pyblish to fix it.'
msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"],
help_msg)
assert not re.search(self.invalid_chars, instance.data["name"]), msg
formatting_data = {"msg": msg}
if re.search(self.invalid_chars, instance.data["name"]):
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"],
help_msg)
assert not re.search(self.invalid_chars, instance.data["subset"]), msg
formatting_data = {"msg": msg}
if re.search(self.invalid_chars, instance.data["subset"]):
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)
@classmethod
def get_replace_chars(cls):

View file

@ -1,6 +1,7 @@
import collections
import pyblish.api
import openpype.api
from openpype.pipeline import PublishXmlValidationError
class ValidateSubsetUniqueness(pyblish.api.ContextPlugin):
@ -27,4 +28,10 @@ class ValidateSubsetUniqueness(pyblish.api.ContextPlugin):
if count > 1]
msg = ("Instance subset names {} are not unique. ".format(non_unique) +
"Remove duplicates via SubsetManager.")
assert not non_unique, msg
formatting_data = {
"non_unique": ",".join(non_unique)
}
if non_unique:
raise PublishXmlValidationError(self, msg,
formatting_data=formatting_data)

View file

@ -18,6 +18,8 @@ from openpype.api import (
get_project_settings
)
UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"])
class ImmutableKeyError(TypeError):
"""Accessed key is immutable so does not allow changes or removements."""
@ -1080,7 +1082,7 @@ class CreateContext:
for instance in cretor_instances:
instance_changes = instance.changes()
if instance_changes:
update_list.append((instance, instance_changes))
update_list.append(UpdateData(instance, instance_changes))
creator = self.creators[identifier]
if update_list:

View file

@ -46,6 +46,11 @@ class BaseCreator:
# - may not be used if `get_icon` is reimplemented
icon = None
# Instance attribute definitions that can be changed per instance
# - returns list of attribute definitions from
# `openpype.pipeline.attribute_definitions`
instance_attr_defs = []
def __init__(
self, create_context, system_settings, project_settings, headless=False
):
@ -56,10 +61,13 @@ class BaseCreator:
# - we may use UI inside processing this attribute should be checked
self.headless = headless
@abstractproperty
@property
def identifier(self):
"""Identifier of creator (must be unique)."""
pass
"""Identifier of creator (must be unique).
Default implementation returns plugin's family.
"""
return self.family
@abstractproperty
def family(self):
@ -92,11 +100,39 @@ class BaseCreator:
pass
@abstractmethod
def collect_instances(self, attr_plugins=None):
def collect_instances(self):
"""Collect existing instances related to this creator plugin.
The implementation differs on host abilities. The creator has to
collect metadata about instance and create 'CreatedInstance' object
which should be added to 'CreateContext'.
Example:
```python
def collect_instances(self):
# Getting existing instances is different per host implementation
for instance_data in pipeline.list_instances():
# Process only instances that were created by this creator
creator_id = instance_data.get("creator_identifier")
if creator_id == self.identifier:
# Create instance object from existing data
instance = CreatedInstance.from_existing(
instance_data, self
)
# Add instance to create context
self._add_instance_to_context(instance)
```
"""
pass
@abstractmethod
def update_instances(self, update_list):
"""Store changes of existing instances so they can be recollected.
Args:
update_list(list<UpdateData>): Gets list of tuples. Each item
contain changed instance and it's changes.
"""
pass
@abstractmethod
@ -180,7 +216,7 @@ class BaseCreator:
list<AbtractAttrDef>: Attribute definitions that can be tweaked for
created instance.
"""
return []
return self.instance_attr_defs
class Creator(BaseCreator):
@ -193,6 +229,9 @@ class Creator(BaseCreator):
# - default_variants may not be used if `get_default_variants` is overriden
default_variants = []
# Default variant used in 'get_default_variant'
default_variant = None
# Short description of family
# - may not be used if `get_description` is overriden
description = None
@ -206,6 +245,10 @@ class Creator(BaseCreator):
# e.g. for buld creators
create_allow_context_change = True
# Precreate attribute definitions showed before creation
# - similar to instance attribute definitions
pre_create_attr_defs = []
@abstractmethod
def create(self, subset_name, instance_data, pre_create_data):
"""Create new instance and store it.
@ -265,7 +308,7 @@ class Creator(BaseCreator):
`get_default_variants` should be used.
"""
return None
return self.default_variant
def get_pre_create_attr_defs(self):
"""Plugin attribute definitions needed for creation.
@ -278,7 +321,7 @@ class Creator(BaseCreator):
list<AbtractAttrDef>: Attribute definitions that can be tweaked for
created instance.
"""
return []
return self.pre_create_attr_defs
class AutoCreator(BaseCreator):

View file

@ -25,7 +25,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
# Update global data to context
context.data.update(create_context.context_data_to_store())
context.data["newPublishing"] = True
# Update context data
for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"):
value = create_context.dbcon.Session.get(key)

View file

@ -0,0 +1,517 @@
---
id: dev_publishing
title: Publishing
sidebar_label: Publishing
---
Publishing workflow consist of 2 parts:
- Creation - Mark what will be published and how.
- Publishing - Use data from creation to go through pyblish process.
OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited plugin actions only for failed validation plugins.
# Creation
Concept of creation does not have to "create" anything but prepare and store metadata about an "instance" (becomes a subset after publish process). Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach that is the reason why it is creator plugin responsibility. Storing the metadata to workfile gives ability to keep values so artist does not have to do create and set what should be published and how over and over.
## Created instance
Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (marked with start `*` in table). The immutable keys are set by creator plugin or create context on initialization and thei values can't change. Instance can have more arbitrary data, for example ids of nodes in scene but keep in mind that some keys are reserved.
| Key | Type | Description |
|---|---|---|
| *id | str | Identifier of metadata type. ATM constant **"pyblish.avalon.instance"** |
| *instance_id | str | Unique ID of instance. Set automatically on instance creation using `str(uuid.uuid4())` |
| *family | str | Instance's family representing type defined by creator plugin. |
| *creator_identifier | str | Identifier of creator that collected/created the instance. |
| *creator_attributes | dict | Dictionary of attributes that are defined by creator plugin (`get_instance_attr_defs`). |
| *publish_attributes | dict | Dictionary of attributes that are defined by publish plugins. |
| variant | str | Variant is entered by artist on creation and may affect **subset**. |
| subset | str | Name of instance. This name will be used as subset name during publishing. Can be changed on context change or variant change. |
| active | bool | Is instance active and will be published or not. |
| asset | str | Name of asset in which context was created. |
| task | str | Name of task in which context was created. Can be set to `None`. |
:::note
Task should not be required until subset name template expect it.
:::
object of **CreatedInstance** has method **data_to_store** which returns dictionary that can be parsed to json string. This method will return all data related to instance so can be re-created using `CreatedInstance.from_existing(data)`.
## Create context
Controller and wrapper around creation is `CreateContext` which cares about loading of plugins needed for creation. And validates required functions in host implementation.
Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger creation itself. Also keeps in mind instance objects by their ids.
Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instance but these methods are not meant to be called directly out of creator. The reason is that is creator's responsibility to remove metadata or decide if should remove the instance.
### Required functions in host implementation
Host implementation **must** have implemented **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instane but are needed for creation and publishing process. Right now are there stored data about enabled/disabled optional publish plugins. When data are not stored and loaded properly reset of publishing will cause that they will be set to default value. Similar to instance data can be context data also parsed to json string.
There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return string showed 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}"`.
Another optional function is **get_current_context**. This function is handy in hosts where is possible to open multiple workfiles in one process so using global context variables is not relevant because artist can switch between opened workfiles without being acknowledged. When function is not implemented or won't return right keys the global context is used.
```json
# Expected keys in output
{
"project_name": "MyProject",
"asset_name": "sq01_sh0010",
"task_name": "Modeling"
}
```
## Create plugin
Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. Create plugins have a lot of responsibility so it is recommended to implement common code per host.
### BaseCreator
Base implementation of creator plugin. It is not recommended to use this class as base for production plugins but rather use one of **AutoCreator** and **Creator** variants.
**Abstractions**
- **`family`** (class attr) - Tells what kind of instance will be created.
```python
class WorkfileCreator(Creator):
family = "workfile"
```
- **`collect_instances`** (method) - Collect already existing instances from workfile and add them to create context. This method is called on initialization or reset of **CreateContext**. Each creator is responsible to find it's instances metadata, convert them to **CreatedInstance** object and add the to create context (`self._add_instance_to_context(instnace_obj)`).
```python
def collect_instances(self):
# Using 'pipeline.list_instances' is just example how to get existing instances from scene
# - getting existing instances is different per host implementation
for instance_data in pipeline.list_instances():
# Process only instances that were created by this creator
creator_id = instance_data.get("creator_identifier")
if creator_id == self.identifier:
# Create instance object from existing data
instance = CreatedInstance.from_existing(
instance_data, self
)
# Add instance to create context
self._add_instance_to_context(instance)
```
- **`create`** (method) - Create new object of **CreatedInstance** store it's metadata to workfile and add the instance into create context. Failed creation should raise **CreatorError** if happens error that can artist fix or give him some useful information. Trigger and implementation differs for **Creator** and **AutoCreator**.
- **`update_instances`** (method) - Update data of instances. Receives tuple with **instance** and **changes**.
```python
def update_instances(self, update_list):
# Loop over changed instances
for instance, changes in update_list:
# Example possible usage of 'changes' to use different node on change
# of node id in instance data (MADE UP)
node = None
if "node_id" in changes:
old_value, new_value = changes["node_id"]
if new_value is not None:
node = pipeline.get_node_by_id(new_value)
if node is None:
node = pipeline.get_node_by_instance_id(instance.id)
# Get node in scene that represents the instance
# Imprind data to a node
pipeline.imprint(node, instance.data_to_store())
# Most implementations will probably ignore 'changes' completely
def update_instances(self, update_list):
for instance, _ in update_list:
# Get node from scene
node = pipeline.get_node_by_instance_id(instance.id)
# Imprint data to node
pipeline.imprint(node, instance.data_to_store())
```
- **`remove_instances`** (method) - Remove instance metadata from workfile and from create context.
```python
# Possible way how to remove instance
def remove_instances(self, instances):
for instance in instances:
# Remove instance metadata from workflle
pipeline.remove_instance(instance.id)
# Remove instance from create context
self._remove_instance_from_context(instance)
# Default implementation of `AutoCreator`
def remove_instances(self, instances):
pass
```
:::note
When host implementation use universal way how to store and load instances you should implement host specific creator plugin base class with implemented **collect_instances**, **update_instances** and **remove_instances**.
:::
**Optional implementations**
- **`enabled`** (attr) - Boolean if creator plugin is enabled and used.
- **`identifier`** (class attr) - Consistent unique string identifier of the creator plugin. Is used to identify source plugin of existing instances. There can't be 2 creator plugins with same identifier. Default implementation returns `family` attribute.
```python
class RenderLayerCreator(Creator):
family = "render"
identifier = "render_layer"
class RenderPassCreator(Creator):
family = "render"
identifier = "render_pass"
```
- **`label`** (attr) - String label of creator plugin which will showed in UI, `identifier` is used when not set. It should be possible to use html tags.
```python
class RenderLayerCreator(Creator):
label = "Render Layer"
```
- **`get_icon`** (attr) - Icon of creator and it's instances. Value can be a path to image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for used version in pyproject.toml). Default implementation return **icon** attribute.
- **`icon`** (method) - Attribute for default implementation of **get_icon**.
```python
class RenderLayerCreator(Creator):
# Use font awesome 5 icon
icon = "fa5.building"
```
- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic type of values for different cases e.g. boolean, number, string, enumerator, etc. Default implementations returns **instance_attr_defs**.
- **`instance_attr_defs`** (attr) - Attribute for default implementation of **get_instance_attr_defs**.
```python
from openpype.pipeline import attribute_definitions
class RenderLayerCreator(Creator):
def get_instance_attr_defs(self):
# Return empty list if '_allow_farm_render' is not enabled (can be set during initialization)
if not self._allow_farm_render:
return []
# Give artist option to change if should be rendered on farm or locally
return [
attribute_definitions.BoolDef(
"render_farm",
default=False,
label="Render on Farm"
)
]
```
- **`get_subset_name`** (method) - Calculate subset name based on passed data. Data can be extended using `get_dynamic_data` method. Default implementation is using `get_subset_name` from `openpype.lib` which is recommended.
- **`get_dynamic_data`** (method) - Can be used to extend data for subset template which may be required in some cases.
### AutoCreator
Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting of all creators.
:::important
**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would lead to create new instance immediately or on refresh.
:::
```python
def __init__(
self, create_context, system_settings, project_settings, *args, **kwargs
):
super(MyCreator, self).__init__(
create_context, system_settings, project_settings, *args, **kwargs
)
# Get variant value from settings
variant_name = (
project_settings["my_host"][self.identifier]["variant"]
).strip()
if not variant_name:
variant_name = "Main"
self._variant_name = variant_name
# Create does not expect any arguments
def create(self):
# Look for existing instance in create context
existing_instance = None
for instance in self.create_context.instances:
if instance.creator_identifier == self.identifier:
existing_instance = instance
break
# Collect current context information
# - variant can be filled from settings
variant = self._variant_name
# Only place where we can look for current context
project_name = io.Session["AVALON_PROJECT"]
asset_name = io.Session["AVALON_ASSET"]
task_name = io.Session["AVALON_TASK"]
host_name = io.Session["AVALON_APP"]
# Create new instance if does not exist yet
if existing_instance is None:
asset_doc = io.find_one({"type": "asset", "name": asset_name})
subset_name = self.get_subset_name(
variant, task_name, asset_doc, project_name, host_name
)
data = {
"asset": asset_name,
"task": task_name,
"variant": variant
}
data.update(self.get_dynamic_data(
variant, task_name, asset_doc, project_name, host_name
))
new_instance = CreatedInstance(
self.family, subset_name, data, self
)
self._add_instance_to_context(new_instance)
# Update instance context if is not the same
elif (
existing_instance["asset"] != asset_name
or existing_instance["task"] != task_name
):
asset_doc = io.find_one({"type": "asset", "name": asset_name})
subset_name = self.get_subset_name(
variant, task_name, asset_doc, project_name, host_name
)
existing_instance["asset"] = asset_name
existing_instance["task"] = task_name
```
### Creator
Implementation of creator plugin that is triggered manually by artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments.
**Abstractions**
- **`create`** (method) - Code where creation of metadata
**Optional implementations**
- **`create_allow_context_change`** (class attr) - Allow to set context in UI before creation. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default.
```python
class BulkRenderCreator(Creator):
create_allow_context_change = False
```
- **`get_default_variants`** (method) - Returns list of default variants that are listed in create dialog for user. Returns **default_variants** attribute by default.
- **`default_variants`** (attr) - Attribute for default implementation of **get_default_variants**.
- **`get_default_variant`** (method) - Returns default variant that is prefilled in UI (value does not have to be in default variants). By default returns **default_variant** attribute. If returns `None` then UI logic will take first item from **get_default_variants** if there is any otherwise **"Main"** is used.
- **`default_variant`** (attr) - Attribute for default implementation of **get_default_variant**.
- **`get_description`** (method) - Returns short string description of creator. Returns **description** attribute by default.
- **`description`** (attr) - Attribute for default implementation of **get_description**.
- **`get_detailed_description`** (method) - Returns detailed string description of creator. Can contain markdown. Returns **detailed_description** attribute by default.
- **`detailed_description`** (attr) - Attribute for default implementation of **get_detailed_description**.
- **`get_pre_create_attr_defs`** (method) - Similar to **get_instance_attr_defs** returns attribute definitions but they are filled before creation. When creation is called from UI the values are passed to **create** method. Returns **pre_create_attr_defs** attribute by default.
- **`pre_create_attr_defs`** (attr) - Attribute for default implementation of **get_pre_create_attr_defs**.
```python
from openpype.pipeline import Creator, attribute_definitions
class CreateRender(Creator):
family = "render"
label = "Render"
icon = "fa.eye"
description = "Render scene viewport"
def __init__(
self, context, system_settings, project_settings, *args, **kwargs
):
super(CreateRender, self).__init__(
context, system_settings, project_settings, *args, **kwargs
)
plugin_settings = (
project_settings["my_host"]["create"][self.__class__.__name__]
)
# Get information if studio has enabled farm publishing
self._allow_farm_render = plugin_settings["allow_farm_render"]
# Get default variants from settings
self.default_variants = plugin_settings["variants"]
def get_instance_attr_defs(self):
# Return empty list if '_allow_farm_render' is not enabled (can be set during initialization)
if not self._allow_farm_render:
return []
# Give artist option to change if should be rendered on farm or locally
return [
attribute_definitions.BoolDef(
"render_farm",
default=False,
label="Render on Farm"
)
]
def get_pre_create_attr_defs(self):
# Give user option to use selection or not
attrs = [
attribute_definitions.BoolDef(
"use_selection",
default=False,
label="Use selection"
)
]
if self._allow_farm_render:
# Set to render on farm in creator dialog
# - this value is not automatically passed to instance attributes
# creator must do that during creation
attrs.append(
attribute_definitions.BoolDef(
"render_farm",
default=False,
label="Render on Farm"
)
)
return attrs
def create(self, subset_name, instance_data, pre_create_data):
# ARGS:
# - 'subset_name' - precalculated subset name
# - 'instance_data' - context data
# - 'asset' - asset name
# - 'task' - task name
# - 'variant' - variant
# - 'family' - instnace family
# Check if should use selection or not
if pre_create_data.get("use_selection"):
items = pipeline.get_selection()
else:
items = [pipeline.create_write()]
# Validations related to selection
if len(items) > 1:
raise CreatorError("Please select only single item at time.")
elif not items:
raise CreatorError("Nothing to create. Select at least one item.")
# Create instence object
new_instance = CreatedInstance(self.family, subset_name, data, self)
# Pass value from pre create attribute to instance
# - use them only when pre create date contain the data
if "render_farm" in pre_create_data:
use_farm = pre_create_data["render_farm"]
new_instance.creator_attributes["render_farm"] = use_farm
# Store metadata to workfile
pipeline.imprint(new_instance.id, new_instance.data_to_store())
# Add instance to context
self._add_instance_to_context(new_instance)
```
# Publish
## Exceptions
OpenPype define few specific exceptions that should be used in publish plugins.
### Validation exception
Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception.
Exception `PublishValidationError` expects 4 arguments:
- **message** Which is not used in UI but for headless publishing.
- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin.
- **description** Detailed description of happened issue where markdown and html can be used.
- **detail** Is optional to give even more detailed information for advanced users. At this moment is detail showed under description but it is in plan to have detail in collapsible widget.
Extended version is `PublishXmlValidationError` which uses xml files with stored descriptions. This helps to avoid having huge markdown texts inside code. The exception has 4 arguments:
- **plugin** The plugin object which raises the exception to find it's related xml file.
- **message** Exception message for publishing without UI or different pyblish UI.
- **key** Optional argument says which error from xml is used as validation plugin may raise error with different messages based on the current errors. Default is **"main"**.
- **formatting_data** Optional dictionary to format data in the error. This is used to fill detailed description with data from the publishing so artist can get more precise information.
**Where and how to create xml file**
Xml files for `PublishXmlValidationError` must be located in **./help** subfolder next to plugin and the filename must match the filename of plugin.
```
# File location related to plugin file
└ publish
├ help
│ ├ validate_scene.xml
│ └ ...
├ validate_scene.py
└ ...
```
Xml file content has **&ltroot&gt** node which may contain any amount of **&lterror&gt** nodes, but each of them must have **id** attribute with unique value. That is then used for **key**. Each error must have **&lttitle&gt** and **&ltdescription&gt** and **&ltdetail&gt**. Text content may contain python formatting keys that can be filled when exception is raised.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Subset context</title>
<description>## Invalid subset context
Context of the given subset doesn't match your current scene.
### How to repair?
Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata.
After that restart publishing with Reload button.
</description>
<detail>
### How could this happen?
The subset was created in different scene with different context
or the scene file was copy pasted from different context.
</detail>
</error>
</root>
```
### Known errors
When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown.
## Plugin extension
Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of most important usages is to be able turn on/off optional plugins.
Attributes are defined by return value of `get_attribute_defs` method. Attribute definitions are for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be re-implemented `convert_attribute_values`. Default implementation just converts the values to right types.
:::important
Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure.
:::
Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`.
```python
import pyblish.api
from openpype.pipeline import (
OpenPypePyblishPluginMixin,
attribute_definitions,
)
# Example context plugin
class MyExtendedPlugin(
pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin
):
optional = True
active = True
@classmethod
def get_attribute_defs(cls):
return [
attribute_definitions.BoolDef(
# Key under which it will be stored
"process",
# Use 'active' as default value
default=cls.active,
# Use plugin label as label for attribute
label=cls.label
)
]
def process_plugin(self, context):
# First check if plugin is optional
if not self.optional:
return True
# Get 'process' key
process_value = (
context.data
.get("publish_attributes", {})
# Attribute values are stored by class names
.get(self.__class__.__name__, {})
# Access the key
.get("process")
)
if process_value or process_value is None:
return True
return False
def process(self, context):
if not self.process_plugin(context):
return
# Do plugin logic
...
```

View file

@ -136,6 +136,13 @@ module.exports = {
"dev_requirements",
"dev_build",
"dev_testing",
"dev_contribute"
"dev_contribute",
{
type: "category",
label: "Hosts integrations",
items: [
"dev_publishing"
]
}
]
};