Merge branch 'develop' into enhancement/OP-7473_multilayout_creator_enhancement

This commit is contained in:
Ondřej Samohel 2024-04-19 14:31:27 +02:00
commit 9aac8699f7
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
86 changed files with 760 additions and 383 deletions

View file

@ -15,6 +15,7 @@ from abc import ABCMeta, abstractmethod
import six
import appdirs
import ayon_api
from semver import VersionInfo
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import Logger, is_dev_mode_enabled
@ -46,6 +47,11 @@ IGNORED_HOSTS_IN_AYON = {
}
IGNORED_MODULES_IN_AYON = set()
# When addon was moved from ayon-core codebase
# - this is used to log the missing addon
MOVED_ADDON_MILESTONE_VERSIONS = {
"applications": VersionInfo(0, 2, 0),
}
# Inherit from `object` for Python 2 hosts
class _ModuleClass(object):
@ -192,6 +198,45 @@ def _get_ayon_addons_information(bundle_info):
return output
def _handle_moved_addons(addon_name, milestone_version, log):
"""Log message that addon version is not compatible with current core.
The function can return path to addon client code, but that can happen
only if ayon-core is used from code (for development), but still
logs a warning.
Args:
addon_name (str): Addon name.
milestone_version (str): Milestone addon version.
log (logging.Logger): Logger object.
Returns:
Union[str, None]: Addon dir or None.
"""
# Handle addons which were moved out of ayon-core
# - Try to fix it by loading it directly from server addons dir in
# ayon-core repository. But that will work only if ayon-core is
# used from code.
addon_dir = os.path.join(
os.path.dirname(os.path.dirname(AYON_CORE_ROOT)),
"server_addon",
addon_name,
"client",
)
if not os.path.exists(addon_dir):
log.error((
"Addon '{}' is not be available."
" Please update applications addon to '{}' or higher."
).format(addon_name, milestone_version))
return None
log.warning((
"Please update '{}' addon to '{}' or higher."
" Using client code from ayon-core repository."
).format(addon_name, milestone_version))
return addon_dir
def _load_ayon_addons(openpype_modules, modules_key, log):
"""Load AYON addons based on information from server.
@ -249,6 +294,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
use_dev_path = dev_addon_info.get("enabled", False)
addon_dir = None
milestone_version = MOVED_ADDON_MILESTONE_VERSIONS.get(addon_name)
if use_dev_path:
addon_dir = dev_addon_info["path"]
if not addon_dir or not os.path.exists(addon_dir):
@ -257,6 +303,16 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
).format(addon_name, addon_version, addon_dir))
continue
elif (
milestone_version is not None
and VersionInfo.parse(addon_version) < milestone_version
):
addon_dir = _handle_moved_addons(
addon_name, milestone_version, log
)
if not addon_dir:
continue
elif addons_dir_exists:
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)
@ -336,66 +392,9 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
return addons_to_skip_in_core
def _load_ayon_core_addons_dir(
ignore_addon_names, openpype_modules, modules_key, log
):
addons_dir = os.path.join(AYON_CORE_ROOT, "addons")
if not os.path.exists(addons_dir):
return
imported_modules = []
# Make sure that addons which already have client code are not loaded
# from core again, with older code
filtered_paths = []
for name in os.listdir(addons_dir):
if name in ignore_addon_names:
continue
path = os.path.join(addons_dir, name)
if os.path.isdir(path):
filtered_paths.append(path)
for path in filtered_paths:
while path in sys.path:
sys.path.remove(path)
sys.path.insert(0, path)
for name in os.listdir(path):
fullpath = os.path.join(path, name)
if os.path.isfile(fullpath):
basename, ext = os.path.splitext(name)
if ext != ".py":
continue
else:
basename = name
try:
module = __import__(basename, fromlist=("",))
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
inspect.isclass(attr)
and issubclass(attr, AYONAddon)
):
new_import_str = "{}.{}".format(modules_key, basename)
sys.modules[new_import_str] = module
setattr(openpype_modules, basename, module)
imported_modules.append(module)
break
except Exception:
log.error(
"Failed to import addon '{}'.".format(fullpath),
exc_info=True
)
return imported_modules
def _load_addons_in_core(
ignore_addon_names, openpype_modules, modules_key, log
):
_load_ayon_core_addons_dir(
ignore_addon_names, openpype_modules, modules_key, log
)
# Add current directory at first place
# - has small differences in import logic
hosts_dir = os.path.join(AYON_CORE_ROOT, "hosts")

View file

@ -41,7 +41,6 @@ class CollectAERender(publish.AbstractCollectRender):
def get_instances(self, context):
instances = []
instances_to_remove = []
app_version = CollectAERender.get_stub().get_app_version()
app_version = app_version[0:4]
@ -117,7 +116,10 @@ class CollectAERender(publish.AbstractCollectRender):
fps=fps,
app_version=app_version,
publish_attributes=inst.data.get("publish_attributes", {}),
file_names=[item.file_name for item in render_q]
file_names=[item.file_name for item in render_q],
# The source instance this render instance replaces
source_instance=inst
)
comp = compositions_by_id.get(comp_id)
@ -145,10 +147,7 @@ class CollectAERender(publish.AbstractCollectRender):
instance.families.remove("review")
instances.append(instance)
instances_to_remove.append(inst)
for instance in instances_to_remove:
context.remove(instance)
return instances
def get_expected_files(self, render_instance):

View file

@ -55,8 +55,7 @@ class BlenderAddon(AYONAddon, IHostAddon):
)
# Define Qt binding if not defined
if not env.get("QT_PREFERRED_BINDING"):
env["QT_PREFERRED_BINDING"] = "PySide2"
env.pop("QT_PREFERRED_BINDING", None)
def get_launch_hook_paths(self, app):
if app.host_name != self.host_name:

View file

@ -31,7 +31,7 @@ class InstallPySideToBlender(PreLaunchHook):
def inner_execute(self):
# Get blender's python directory
version_regex = re.compile(r"^[2-4]\.[0-9]+$")
version_regex = re.compile(r"^([2-4])\.[0-9]+$")
platform = system().lower()
executable = self.launch_context.executable.executable_path
@ -42,7 +42,8 @@ class InstallPySideToBlender(PreLaunchHook):
if os.path.basename(executable).lower() != expected_executable:
self.log.info((
f"Executable does not lead to {expected_executable} file."
"Can't determine blender's python to check/install PySide2."
"Can't determine blender's python to check/install"
" Qt binding."
))
return
@ -73,6 +74,15 @@ class InstallPySideToBlender(PreLaunchHook):
return
version_subfolder = version_subfolders[0]
before_blender_4 = False
if int(version_regex.match(version_subfolder).group(1)) < 4:
before_blender_4 = True
# Blender 4 has Python 3.11 which does not support 'PySide2'
# QUESTION could we always install PySide6?
qt_binding = "PySide2" if before_blender_4 else "PySide6"
# Use PySide6 6.6.3 because 6.7.0 had a bug
# - 'QTextEdit' can't be added to 'QBoxLayout'
qt_binding_version = None if before_blender_4 else "6.6.3"
python_dir = os.path.join(versions_dir, version_subfolder, "python")
python_lib = os.path.join(python_dir, "lib")
@ -116,22 +126,41 @@ class InstallPySideToBlender(PreLaunchHook):
return
# Check if PySide2 is installed and skip if yes
if self.is_pyside_installed(python_executable):
if self.is_pyside_installed(python_executable, qt_binding):
self.log.debug("Blender has already installed PySide2.")
return
# Install PySide2 in blender's python
if platform == "windows":
result = self.install_pyside_windows(python_executable)
result = self.install_pyside_windows(
python_executable,
qt_binding,
qt_binding_version,
before_blender_4,
)
else:
result = self.install_pyside(python_executable)
result = self.install_pyside(
python_executable,
qt_binding,
qt_binding_version,
)
if result:
self.log.info("Successfully installed PySide2 module to blender.")
self.log.info(
f"Successfully installed {qt_binding} module to blender."
)
else:
self.log.warning("Failed to install PySide2 module to blender.")
self.log.warning(
f"Failed to install {qt_binding} module to blender."
)
def install_pyside_windows(self, python_executable):
def install_pyside_windows(
self,
python_executable,
qt_binding,
qt_binding_version,
before_blender_4,
):
"""Install PySide2 python module to blender's python.
Installation requires administration rights that's why it is required
@ -139,7 +168,6 @@ class InstallPySideToBlender(PreLaunchHook):
administration rights.
"""
try:
import win32api
import win32con
import win32process
import win32event
@ -150,12 +178,37 @@ class InstallPySideToBlender(PreLaunchHook):
self.log.warning("Couldn't import \"pywin32\" modules")
return
if qt_binding_version:
qt_binding = f"{qt_binding}=={qt_binding_version}"
try:
# Parameters
# - use "-m pip" as module pip to install PySide2 and argument
# "--ignore-installed" is to force install module to blender's
# site-packages and make sure it is binary compatible
parameters = "-m pip install --ignore-installed PySide2"
fake_exe = "fake.exe"
site_packages_prefix = os.path.dirname(
os.path.dirname(python_executable)
)
args = [
fake_exe,
"-m",
"pip",
"install",
"--ignore-installed",
qt_binding,
]
if not before_blender_4:
# Define prefix for site package
# Python in blender 4.x is installing packages in AppData and
# not in blender's directory.
args.extend(["--prefix", site_packages_prefix])
parameters = (
subprocess.list2cmdline(args)
.lstrip(fake_exe)
.lstrip(" ")
)
# Execute command and ask for administrator's rights
process_info = ShellExecuteEx(
@ -173,20 +226,29 @@ class InstallPySideToBlender(PreLaunchHook):
except pywintypes.error:
pass
def install_pyside(self, python_executable):
"""Install PySide2 python module to blender's python."""
def install_pyside(
self,
python_executable,
qt_binding,
qt_binding_version,
):
"""Install Qt binding python module to blender's python."""
if qt_binding_version:
qt_binding = f"{qt_binding}=={qt_binding_version}"
try:
# Parameters
# - use "-m pip" as module pip to install PySide2 and argument
# - use "-m pip" as module pip to install qt binding and argument
# "--ignore-installed" is to force install module to blender's
# site-packages and make sure it is binary compatible
# TODO find out if blender 4.x on linux/darwin does install
# qt binding to correct place.
args = [
python_executable,
"-m",
"pip",
"install",
"--ignore-installed",
"PySide2",
qt_binding,
]
process = subprocess.Popen(
args, stdout=subprocess.PIPE, universal_newlines=True
@ -203,13 +265,15 @@ class InstallPySideToBlender(PreLaunchHook):
except subprocess.SubprocessError:
pass
def is_pyside_installed(self, python_executable):
def is_pyside_installed(self, python_executable, qt_binding):
"""Check if PySide2 module is in blender's pip list.
Check that PySide2 is installed directly in blender's site-packages.
It is possible that it is installed in user's site-packages but that
may be incompatible with blender's python.
"""
qt_binding_low = qt_binding.lower()
# Get pip list from blender's python executable
args = [python_executable, "-m", "pip", "list"]
process = subprocess.Popen(args, stdout=subprocess.PIPE)
@ -226,6 +290,6 @@ class InstallPySideToBlender(PreLaunchHook):
if not line:
continue
package_name = line[0:package_len].strip()
if package_name.lower() == "pyside2":
if package_name.lower() == qt_binding_low:
return True
return False

View file

@ -167,7 +167,7 @@ class JsonLayoutLoader(plugin.AssetLoader):
asset_group.empty_display_type = 'SINGLE_ARROW'
avalon_container.objects.link(asset_group)
self._process(libpath, asset, asset_group, None)
self._process(libpath, asset_name, asset_group, None)
bpy.context.scene.collection.objects.link(asset_group)

View file

@ -2,6 +2,7 @@ import os
import bpy
from ayon_core.lib import BoolDef
from ayon_core.pipeline import publish
from ayon_core.hosts.blender.api import plugin
@ -17,6 +18,8 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
if not self.is_active(instance.data):
return
attr_values = self.get_attr_values_from_data(instance.data)
# Define extract output file path
stagingdir = self.staging_dir(instance)
folder_name = instance.data["folderEntity"]["name"]
@ -46,7 +49,8 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
bpy.ops.wm.alembic_export(
filepath=filepath,
selected=True,
flatten=False
flatten=False,
subdiv_schema=attr_values.get("subdiv_schema", False)
)
plugin.deselect_all()
@ -65,6 +69,21 @@ class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin):
self.log.debug("Extracted instance '%s' to: %s",
instance.name, representation)
@classmethod
def get_attribute_defs(cls):
return [
BoolDef(
"subdiv_schema",
label="Alembic Mesh Subdiv Schema",
tooltip="Export Meshes using Alembic's subdivision schema.\n"
"Enabling this includes creases with the export but "
"excludes the mesh's normals.\n"
"Enabling this usually result in smaller file size "
"due to lack of normals.",
default=False
)
]
class ExtractModelABC(ExtractABC):
"""Extract model as ABC."""

View file

@ -3,8 +3,8 @@ import sys
import re
import contextlib
from ayon_core.lib import Logger
from ayon_core.lib import Logger, BoolDef, UILabelDef
from ayon_core.style import load_stylesheet
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.context_tools import get_current_folder_entity
@ -181,7 +181,6 @@ def validate_comp_prefs(comp=None, force_repair=False):
from . import menu
from ayon_core.tools.utils import SimplePopup
from ayon_core.style import load_stylesheet
dialog = SimplePopup(parent=menu.menu)
dialog.setWindowTitle("Fusion comp has invalid configuration")
@ -340,9 +339,7 @@ def prompt_reset_context():
from ayon_core.tools.attribute_defs.dialog import (
AttributeDefinitionsDialog
)
from ayon_core.style import load_stylesheet
from ayon_core.lib import BoolDef, UILabelDef
from qtpy import QtWidgets, QtCore
from qtpy import QtCore
definitions = [
UILabelDef(

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib import PreLaunchHook
from ayon_applications import PreLaunchHook
from ayon_core.hosts.fusion import FUSION_HOST_DIR

View file

@ -85,7 +85,6 @@ class InstallPySideToFusion(PreLaunchHook):
administration rights.
"""
try:
import win32api
import win32con
import win32process
import win32event

View file

@ -37,14 +37,13 @@ class CollectFusionRender(
aspect_x = comp_frame_format_prefs["AspectX"]
aspect_y = comp_frame_format_prefs["AspectY"]
instances = []
instances_to_remove = []
current_file = context.data["currentFile"]
version = context.data["version"]
project_entity = context.data["projectEntity"]
instances = []
for inst in context:
if not inst.data.get("active", True):
continue
@ -91,7 +90,10 @@ class CollectFusionRender(
frameStep=1,
fps=comp_frame_format_prefs.get("Rate"),
app_version=comp.GetApp().Version,
publish_attributes=inst.data.get("publish_attributes", {})
publish_attributes=inst.data.get("publish_attributes", {}),
# The source instance this render instance replaces
source_instance=inst
)
render_target = inst.data["creator_attributes"]["render_target"]
@ -114,13 +116,7 @@ class CollectFusionRender(
# to skip ExtractReview locally
instance.families.remove("review")
# add new instance to the list and remove the original
# instance since it is not needed anymore
instances.append(instance)
instances_to_remove.append(inst)
for instance in instances_to_remove:
context.remove(instance)
return instances

View file

@ -92,10 +92,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
folder_path, folder_name = self._get_folder_data(tag_data)
product_name = tag_data.get("productName")
if product_name is None:
product_name = tag_data["subset"]
families = [str(f) for f in tag_data["families"]]
# TODO: remove backward compatibility
@ -293,7 +289,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin):
label += " {}".format(product_name)
data.update({
"name": "{}_{}".format(folder_path, subset),
"name": "{}_{}".format(folder_path, product_name),
"label": label,
"productName": product_name,
"productType": product_type,

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating alembic camera products."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance, CreatorError
from ayon_core.pipeline import CreatorError
import hou
@ -23,7 +23,7 @@ class CreateAlembicCamera(plugin.HoudiniCreator):
instance = super(CreateAlembicCamera, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))
parms = {

View file

@ -29,7 +29,7 @@ class CreateArnoldAss(plugin.HoudiniCreator):
instance = super(CreateArnoldAss, self).create(
product_name,
instance_data,
pre_create_data) # type: plugin.CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -31,7 +31,7 @@ class CreateArnoldRop(plugin.HoudiniCreator):
instance = super(CreateArnoldRop, self).create(
product_name,
instance_data,
pre_create_data) # type: plugin.CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating pointcache bgeo files."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance, CreatorError
from ayon_core.pipeline import CreatorError
import hou
from ayon_core.lib import EnumDef, BoolDef
@ -25,7 +25,7 @@ class CreateBGEO(plugin.HoudiniCreator):
instance = super(CreateBGEO, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating composite sequences."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance, CreatorError
from ayon_core.pipeline import CreatorError
import hou
@ -25,7 +25,7 @@ class CreateCompositeSequence(plugin.HoudiniCreator):
instance = super(CreateCompositeSequence, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))
filepath = "{}{}".format(

View file

@ -78,7 +78,7 @@ class CreateHDA(plugin.HoudiniCreator):
instance = super(CreateHDA, self).create(
product_name,
instance_data,
pre_create_data) # type: plugin.CreatedInstance
pre_create_data)
return instance

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin to create Karma ROP."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
from ayon_core.lib import BoolDef, EnumDef, NumberDef
@ -25,7 +24,7 @@ class CreateKarmaROP(plugin.HoudiniCreator):
instance = super(CreateKarmaROP, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating pointcache alembics."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
from ayon_core.lib import BoolDef
@ -22,7 +21,7 @@ class CreateMantraIFD(plugin.HoudiniCreator):
instance = super(CreateMantraIFD, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin to create Mantra ROP."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
from ayon_core.lib import EnumDef, BoolDef
@ -28,7 +27,7 @@ class CreateMantraROP(plugin.HoudiniCreator):
instance = super(CreateMantraROP, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating USDs."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
import hou
@ -22,7 +21,7 @@ class CreateUSD(plugin.HoudiniCreator):
instance = super(CreateUSD, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating USD renders."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
class CreateUSDRender(plugin.HoudiniCreator):
@ -23,7 +22,7 @@ class CreateUSDRender(plugin.HoudiniCreator):
instance = super(CreateUSDRender, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating VDB Caches."""
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance
from ayon_core.lib import BoolDef
import hou
@ -26,7 +25,7 @@ class CreateVDBCache(plugin.HoudiniCreator):
instance = super(CreateVDBCache, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))
file_path = "{}{}".format(

View file

@ -3,7 +3,7 @@
import hou
from ayon_core.hosts.houdini.api import plugin
from ayon_core.pipeline import CreatedInstance, CreatorError
from ayon_core.pipeline import CreatorError
from ayon_core.lib import EnumDef, BoolDef
@ -31,7 +31,7 @@ class CreateVrayROP(plugin.HoudiniCreator):
instance = super(CreateVrayROP, self).create(
product_name,
instance_data,
pre_create_data) # type: CreatedInstance
pre_create_data)
instance_node = hou.node(instance.get("instance_node"))

View file

@ -1,9 +1,21 @@
from collections import deque
import pyblish.api
from ayon_core.pipeline import registered_host
def collect_input_containers(nodes):
def get_container_members(container):
node = container["node"]
# Usually the loaded containers don't have any complex references
# and the contained children should be all we need. So we disregard
# checking for .references() on the nodes.
members = set(node.allSubChildren())
members.add(node) # include the node itself
return members
def collect_input_containers(containers, nodes):
"""Collect containers that contain any of the node in `nodes`.
This will return any loaded Avalon container that contains at least one of
@ -11,30 +23,13 @@ def collect_input_containers(nodes):
there are member nodes of that container.
Returns:
list: Input avalon containers
list: Loaded containers that contain the `nodes`
"""
# Lookup by node ids
lookup = frozenset(nodes)
containers = []
host = registered_host()
for container in host.ls():
node = container["node"]
# Usually the loaded containers don't have any complex references
# and the contained children should be all we need. So we disregard
# checking for .references() on the nodes.
members = set(node.allSubChildren())
members.add(node) # include the node itself
# If there's an intersection
if not lookup.isdisjoint(members):
containers.append(container)
return containers
# Assume the containers have collected their cached '_members' data
# in the collector.
return [container for container in containers
if any(node in container["_members"] for node in nodes)]
def iter_upstream(node):
@ -54,7 +49,7 @@ def iter_upstream(node):
)
# Initialize process queue with the node's ancestors itself
queue = list(upstream)
queue = deque(upstream)
collected = set(upstream)
# Traverse upstream references for all nodes and yield them as we
@ -72,6 +67,10 @@ def iter_upstream(node):
# Include the references' ancestors that have not been collected yet.
for reference in references:
if reference in collected:
# Might have been collected in previous iteration
continue
ancestors = reference.inputAncestors(
include_ref_inputs=True, follow_subnets=True
)
@ -108,13 +107,32 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
)
return
# Collect all upstream parents
nodes = list(iter_upstream(output))
nodes.append(output)
# For large scenes the querying of "host.ls()" can be relatively slow
# e.g. up to a second. Many instances calling it easily slows this
# down. As such, we cache it so we trigger it only once.
# todo: Instead of hidden cache make "CollectContainers" plug-in
cache_key = "__cache_containers"
scene_containers = instance.context.data.get(cache_key, None)
if scene_containers is None:
# Query the scenes' containers if there's no cache yet
host = registered_host()
scene_containers = list(host.ls())
for container in scene_containers:
# Embed the members into the container dictionary
container_members = set(get_container_members(container))
container["_members"] = container_members
instance.context.data[cache_key] = scene_containers
# Collect containers for the given set of nodes
containers = collect_input_containers(nodes)
inputs = []
if scene_containers:
# Collect all upstream parents
nodes = list(iter_upstream(output))
nodes.append(output)
# Collect containers for the given set of nodes
containers = collect_input_containers(scene_containers, nodes)
inputs = [c["representation"] for c in containers]
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.debug("Collected inputs: %s" % inputs)

View file

@ -3,7 +3,6 @@ import pyblish.api
from ayon_core.lib import version_up
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.publish import get_errored_plugins_from_context
from ayon_core.hosts.houdini.api import HoudiniHost
from ayon_core.pipeline.publish import KnownPublishError
@ -39,7 +38,7 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin):
)
# Filename must not have changed since collecting
host = registered_host() # type: HoudiniHost
host = registered_host()
current_file = host.current_file()
if context.data["currentFile"] != current_file:
raise KnownPublishError(

View file

@ -8,10 +8,15 @@ from typing import Any, Dict, Union
import six
import ayon_api
from ayon_core.pipeline import get_current_project_name, colorspace
from ayon_core.pipeline import (
get_current_project_name,
get_current_folder_path,
get_current_task_name,
colorspace
)
from ayon_core.settings import get_project_settings
from ayon_core.pipeline.context_tools import (
get_current_folder_entity,
get_current_task_entity
)
from ayon_core.style import load_stylesheet
from pymxs import runtime as rt
@ -221,41 +226,30 @@ def reset_scene_resolution():
scene resolution can be overwritten by a folder if the folder.attrib
contains any information regarding scene resolution.
"""
folder_entity = get_current_folder_entity(
fields={"attrib.resolutionWidth", "attrib.resolutionHeight"}
)
folder_attributes = folder_entity["attrib"]
width = int(folder_attributes["resolutionWidth"])
height = int(folder_attributes["resolutionHeight"])
task_attributes = get_current_task_entity(fields={"attrib"})["attrib"]
width = int(task_attributes["resolutionWidth"])
height = int(task_attributes["resolutionHeight"])
set_scene_resolution(width, height)
def get_frame_range(folder_entiy=None) -> Union[Dict[str, Any], None]:
"""Get the current folder frame range and handles.
def get_frame_range(task_entity=None) -> Union[Dict[str, Any], None]:
"""Get the current task frame range and handles
Args:
folder_entiy (dict): Folder eneity.
task_entity (dict): Task Entity.
Returns:
dict: with frame start, frame end, handle start, handle end.
"""
# Set frame start/end
if folder_entiy is None:
folder_entiy = get_current_folder_entity()
folder_attributes = folder_entiy["attrib"]
frame_start = folder_attributes.get("frameStart")
frame_end = folder_attributes.get("frameEnd")
if frame_start is None or frame_end is None:
return {}
frame_start = int(frame_start)
frame_end = int(frame_end)
handle_start = int(folder_attributes.get("handleStart", 0))
handle_end = int(folder_attributes.get("handleEnd", 0))
if task_entity is None:
task_entity = get_current_task_entity(fields={"attrib"})
task_attributes = task_entity["attrib"]
frame_start = int(task_attributes["frameStart"])
frame_end = int(task_attributes["frameEnd"])
handle_start = int(task_attributes["handleStart"])
handle_end = int(task_attributes["handleEnd"])
frame_start_handle = frame_start - handle_start
frame_end_handle = frame_end + handle_end
@ -281,9 +275,9 @@ def reset_frame_range(fps: bool = True):
scene frame rate in frames-per-second.
"""
if fps:
project_name = get_current_project_name()
project_entity = ayon_api.get_project(project_name)
fps_number = float(project_entity["attrib"].get("fps"))
task_entity = get_current_task_entity()
task_attributes = task_entity["attrib"]
fps_number = float(task_attributes["fps"])
rt.frameRate = fps_number
frame_range = get_frame_range()

View file

@ -42,7 +42,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin,
return
frame_range = get_frame_range(
instance.data["folderEntity"])
instance.data["taskEntity"])
inst_frame_start = instance.data.get("frameStartHandle")
inst_frame_end = instance.data.get("frameEndHandle")

View file

@ -38,7 +38,7 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin,
context_label = "{} > {}".format(*context)
instance_label = "{} > {}".format(folderPath, task)
message = (
"Instance '{}' publishes to different folder or task "
"Instance '{}' publishes to different context(folder or task) "
"than current context: {}. Current context: {}".format(
instance.name, instance_label, context_label
)
@ -46,7 +46,7 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin,
raise PublishValidationError(
message=message,
description=(
"## Publishing to a different context folder or task\n"
"## Publishing to a different context data(folder or task)\n"
"There are publish instances present which are publishing "
"into a different folder path or task than your current context.\n\n"
"Usually this is not what you want but there can be cases "

View file

@ -7,7 +7,10 @@ from ayon_core.pipeline.publish import (
RepairAction,
PublishValidationError
)
from ayon_core.hosts.max.api.lib import reset_scene_resolution
from ayon_core.hosts.max.api.lib import (
reset_scene_resolution,
imprint
)
class ValidateResolutionSetting(pyblish.api.InstancePlugin,
@ -25,8 +28,10 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
if not self.is_active(instance.data):
return
width, height = self.get_folder_resolution(instance)
current_width = rt.renderWidth
current_height = rt.renderHeight
current_width, current_height = (
self.get_current_resolution(instance)
)
if current_width != width and current_height != height:
raise PublishValidationError("Resolution Setting "
"not matching resolution "
@ -41,12 +46,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
"not matching resolution set "
"on asset or shot.")
def get_folder_resolution(self, instance):
folder_entity = instance.data["folderEntity"]
if folder_entity:
folder_attributes = folder_entity["attrib"]
width = folder_attributes["resolutionWidth"]
height = folder_attributes["resolutionHeight"]
def get_current_resolution(self, instance):
return rt.renderWidth, rt.renderHeight
@classmethod
def get_folder_resolution(cls, instance):
task_entity = instance.data.get("taskEntity")
if task_entity:
task_attributes = task_entity["attrib"]
width = task_attributes["resolutionWidth"]
height = task_attributes["resolutionHeight"]
return int(width), int(height)
# Defaults if not found in folder entity
@ -55,3 +64,29 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
@classmethod
def repair(cls, instance):
reset_scene_resolution()
class ValidateReviewResolutionSetting(ValidateResolutionSetting):
families = ["review"]
optional = True
actions = [RepairAction]
def get_current_resolution(self, instance):
current_width = instance.data["review_width"]
current_height = instance.data["review_height"]
return current_width, current_height
@classmethod
def repair(cls, instance):
context_width, context_height = (
cls.get_folder_resolution(instance)
)
creator_attrs = instance.data["creator_attributes"]
creator_attrs["review_width"] = context_width
creator_attrs["review_height"] = context_height
creator_attrs_data = {
"creator_attributes": creator_attrs
}
# update the width and height of review
# data in creator_attributes
imprint(instance.data["instance_node"], creator_attrs_data)

View file

@ -2,8 +2,6 @@
"""Tools to work with FBX."""
import logging
from pyblish.api import Instance
from maya import cmds # noqa
import maya.mel as mel # noqa
from ayon_core.hosts.maya.api.lib import maintained_selection
@ -146,7 +144,6 @@ class FBXExtractor:
return options
def set_options_from_instance(self, instance):
# type: (Instance) -> None
"""Sets FBX export options from data in the instance.
Args:

View file

@ -1917,6 +1917,29 @@ def apply_attributes(attributes, nodes_by_id):
set_attribute(attr, value, node)
def is_valid_reference_node(reference_node):
"""Return whether Maya considers the reference node a valid reference.
Maya might report an error when using `maya.cmds.referenceQuery`:
Reference node 'reference_node' is not associated with a reference file.
Note that this does *not* check whether the reference node points to an
existing file. Instead it only returns whether maya considers it valid
and thus is not an unassociated reference node
Arguments:
reference_node (str): Reference node name
Returns:
bool: Whether reference node is a valid reference
"""
sel = OpenMaya.MSelectionList()
sel.add(reference_node)
depend_node = sel.getDependNode(0)
return OpenMaya.MFnReference(depend_node).isValidReference()
def get_container_members(container):
"""Returns the members of a container.
This includes the nodes from any loaded references in the container.
@ -1942,7 +1965,16 @@ def get_container_members(container):
if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"):
continue
reference_members = cmds.referenceQuery(ref, nodes=True, dagPath=True)
try:
reference_members = cmds.referenceQuery(ref,
nodes=True,
dagPath=True)
except RuntimeError:
# Ignore reference nodes that are not associated with a
# referenced file on which `referenceQuery` command fails
if not is_valid_reference_node(ref):
continue
raise
reference_members = cmds.ls(reference_members,
long=True,
objectsOnly=True)
@ -4238,6 +4270,9 @@ def get_reference_node(members, log=None):
if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"):
continue
if not is_valid_reference_node(ref):
continue
references.add(ref)
assert references, "No reference node found in container"
@ -4268,15 +4303,19 @@ def get_reference_node_parents(ref):
list: The upstream parent reference nodes.
"""
parent = cmds.referenceQuery(ref,
referenceNode=True,
parent=True)
def _get_parent(reference_node):
"""Return parent reference node, but ignore invalid reference nodes"""
if not is_valid_reference_node(reference_node):
return
return cmds.referenceQuery(reference_node,
referenceNode=True,
parent=True)
parent = _get_parent(ref)
parents = []
while parent:
parents.append(parent)
parent = cmds.referenceQuery(parent,
referenceNode=True,
parent=True)
parent = _get_parent(parent)
return parents

View file

@ -37,7 +37,7 @@ class ConnectGeometry(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -36,7 +36,7 @@ class ConnectXgen(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -39,7 +39,7 @@ class ConnectYetiRig(InventoryAction):
repre_id = container["representation"]
repre_context = repre_contexts_by_id[repre_id]
product_type = repre_context["prouct"]["productType"]
product_type = repre_context["product"]["productType"]
containers_by_product_type.setdefault(product_type, [])
containers_by_product_type[product_type].append(container)

View file

@ -12,7 +12,7 @@ class CollectFileDependencies(pyblish.api.ContextPlugin):
families = ["renderlayer"]
@classmethod
def apply_settings(cls, project_settings, system_settings):
def apply_settings(cls, project_settings):
# Disable plug-in if not used for deadline submission anyway
settings = project_settings["deadline"]["publish"]["MayaSubmitDeadline"] # noqa
cls.enabled = settings.get("asset_dependencies", True)

View file

@ -299,4 +299,10 @@ def transfer_image_planes(source_cameras, target_cameras,
def _attach_image_plane(camera, image_plane):
cmds.imagePlane(image_plane, edit=True, detach=True)
# Attaching to a camera resets it to identity size, so we counter that
size_x = cmds.getAttr(f"{image_plane}.sizeX")
size_y = cmds.getAttr(f"{image_plane}.sizeY")
cmds.imagePlane(image_plane, edit=True, camera=camera)
cmds.setAttr(f"{image_plane}.sizeX", size_x)
cmds.setAttr(f"{image_plane}.sizeY", size_y)

View file

@ -2,7 +2,6 @@ from maya import cmds
import pyblish.api
from ayon_core.pipeline.publish import (
ValidateContentsOrder,
RepairContextAction,
PublishValidationError
)

View file

@ -45,6 +45,11 @@ class ValidateMeshNgons(pyblish.api.InstancePlugin,
# Get all faces
faces = ['{0}.f[*]'.format(node) for node in meshes]
# Skip meshes that for some reason have no faces, e.g. empty meshes
faces = cmds.ls(faces)
if not faces:
return []
# Filter to n-sided polygon faces (ngons)
invalid = lib.polyConstraint(faces,
t=0x0008, # type=face

View file

@ -1,3 +1,5 @@
import inspect
from maya import cmds
import pyblish.api
@ -29,8 +31,8 @@ class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin,
actions = [ayon_core.hosts.maya.api.action.SelectInvalidAction,
RepairAction]
@staticmethod
def get_invalid(instance):
@classmethod
def get_invalid(cls, instance):
meshes = cmds.ls(instance, type='mesh', long=True)
@ -40,6 +42,11 @@ class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin,
# Get existing mapping of uv sets by index
indices = cmds.polyUVSet(mesh, query=True, allUVSetsIndices=True)
maps = cmds.polyUVSet(mesh, query=True, allUVSets=True)
if not indices or not maps:
cls.log.warning("Mesh has no UV set: %s", mesh)
invalid.append(mesh)
continue
mapping = dict(zip(indices, maps))
# Get the uv set at index zero.
@ -56,8 +63,14 @@ class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin,
invalid = self.get_invalid(instance)
if invalid:
invalid_list = "\n".join(f"- {node}" for node in invalid)
raise PublishValidationError(
"Meshes found without 'map1' UV set: {0}".format(invalid))
"Meshes found without 'map1' UV set:\n"
"{0}".format(invalid_list),
description=self.get_description()
)
@classmethod
def repair(cls, instance):
@ -68,6 +81,12 @@ class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin,
# Get existing mapping of uv sets by index
indices = cmds.polyUVSet(mesh, query=True, allUVSetsIndices=True)
maps = cmds.polyUVSet(mesh, query=True, allUVSets=True)
if not indices or not maps:
# No UV set exist at all, create a `map1` uv set
# This may fail silently if the mesh has no geometry at all
cmds.polyUVSet(mesh, create=True, uvSet="map1")
continue
mapping = dict(zip(indices, maps))
# Ensure there is no uv set named map1 to avoid
@ -97,3 +116,23 @@ class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin,
rename=True,
uvSet=original,
newUVSet="map1")
@staticmethod
def get_description():
return inspect.cleandoc("""### Mesh found without map1 uv set
A mesh must have a default UV set named `map1` to adhere to the default
mesh behavior of Maya meshes.
There may be meshes that:
- Have no UV set
- Have no `map1` uv set but are using a different name
- Have a `map1` uv set, but it's not the default (first index)
#### Repair
Using repair will try to make the first UV set the `map1` uv set. If it
does not exist yet it will be created or renames the current first
UV set to `map1`.
""")

View file

@ -1,17 +1,27 @@
import inspect
import uuid
from collections import defaultdict
import pyblish.api
import ayon_core.hosts.maya.api.action
from ayon_core.hosts.maya.api import lib
from ayon_core.pipeline.publish import (
OptionalPyblishPluginMixin, PublishValidationError, ValidatePipelineOrder)
from ayon_api import get_folders
def is_valid_uuid(value) -> bool:
"""Return whether value is a valid UUID"""
try:
uuid.UUID(value)
except ValueError:
return False
return True
class ValidateNodeIDsRelated(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate nodes have a related Colorbleed Id to the
instance.data[folderPath]
"""
"""Validate nodes have a related `cbId` to the instance.data[folderPath]"""
order = ValidatePipelineOrder
label = 'Node Ids Related (ID)'
@ -39,21 +49,24 @@ class ValidateNodeIDsRelated(pyblish.api.InstancePlugin,
# Ensure all nodes have a cbId
invalid = self.get_invalid(instance)
if invalid:
invalid_list = "\n".join(f"- {node}" for node in sorted(invalid))
raise PublishValidationError((
"Nodes IDs found that are not related to folder '{}' : {}"
).format(
instance.data["folderPath"], invalid
))
"Nodes IDs found that are not related to folder '{}':\n{}"
).format(instance.data["folderPath"], invalid_list),
description=self.get_description()
)
@classmethod
def get_invalid(cls, instance):
"""Return the member nodes that are invalid"""
invalid = list()
folder_id = instance.data["folderEntity"]["id"]
# We do want to check the referenced nodes as we it might be
# We do want to check the referenced nodes as it might be
# part of the end product
invalid = list()
nodes_by_other_folder_ids = defaultdict(set)
for node in instance:
_id = lib.get_id(node)
if not _id:
@ -62,5 +75,48 @@ class ValidateNodeIDsRelated(pyblish.api.InstancePlugin,
node_folder_id = _id.split(":", 1)[0]
if node_folder_id != folder_id:
invalid.append(node)
nodes_by_other_folder_ids[node_folder_id].add(node)
# Log what other assets were found.
if nodes_by_other_folder_ids:
project_name = instance.context.data["projectName"]
other_folder_ids = set(nodes_by_other_folder_ids.keys())
# Remove folder ids that are not valid UUID identifiers, these
# may be legacy OpenPype ids
other_folder_ids = {folder_id for folder_id in other_folder_ids
if is_valid_uuid(folder_id)}
if not other_folder_ids:
return invalid
folder_entities = get_folders(project_name=project_name,
folder_ids=other_folder_ids,
fields=["path"])
if folder_entities:
# Log names of other assets detected
# We disregard logging nodes/ids for asset ids where no asset
# was found in the database because ValidateNodeIdsInDatabase
# takes care of that.
folder_paths = {entity["path"] for entity in folder_entities}
cls.log.error(
"Found nodes related to other folders:\n{}".format(
"\n".join(f"- {path}" for path in sorted(folder_paths))
)
)
return invalid
@staticmethod
def get_description():
return inspect.cleandoc("""### Node IDs must match folder id
The node ids must match the folder entity id you are publishing to.
Usually these mismatch occurs if you are re-using nodes from another
folder or project.
#### How to repair?
The repair action will regenerate new ids for
the invalid nodes to match the instance's folder.
""")

View file

@ -46,6 +46,6 @@ class ValidateSceneSetWorkspace(pyblish.api.ContextPlugin):
raise PublishValidationError(
"Maya workspace is not set correctly.\n\n"
f"Current workfile `{scene_name}` is not inside the "
"current Maya project root directory `{root_dir}`.\n\n"
f"current Maya project root directory `{root_dir}`.\n\n"
"Please use Workfile app to re-save."
)

View file

@ -5,7 +5,7 @@ import sys
import six
import random
import string
from collections import OrderedDict, defaultdict
from collections import defaultdict
from ayon_core.settings import get_current_project_settings
from ayon_core.lib import (

View file

@ -144,7 +144,8 @@ class CreateTextures(Creator):
9: "512",
10: "1024",
11: "2048",
12: "4096"
12: "4096",
13: "8192"
},
default=None,
label="Size"),

View file

@ -402,7 +402,7 @@ or updating already created. Publishing will create OTIO file.
):
continue
instance = self._make_product_instance(
self._make_product_instance(
otio_clip,
product_type_preset,
deepcopy(base_instance_data),

View file

@ -1,6 +1,5 @@
import os
from ayon_core.lib import StringTemplate
from ayon_core.pipeline import (
registered_host,
get_current_context,
@ -111,8 +110,6 @@ class LoadWorkfile(plugin.Loader):
data["version"] = version
filename = StringTemplate.format_strict_template(
file_template, data
)
filename = work_template["file"].format_strict(data)
path = os.path.join(work_root, filename)
host.save_workfile(path)

View file

@ -28,9 +28,11 @@ from .pipeline import (
)
__all__ = [
"UnrealActorCreator",
"UnrealAssetCreator",
"Loader",
"install",
"uninstall",
"Loader",
"ls",
"publish",
"containerise",

View file

@ -94,8 +94,12 @@ def prepare_template_data(fill_pairs):
output = {}
for item in valid_items:
keys, value = item
upper_value = value.upper()
capitalized_value = _capitalize_value(value)
# Convert only string values
if isinstance(value, str):
upper_value = value.upper()
capitalized_value = _capitalize_value(value)
else:
upper_value = capitalized_value = value
first_key = keys.pop(0)
if not keys:

View file

@ -103,17 +103,17 @@ class FusionSubmitDeadline(
# Collect all saver instances in context that are to be rendered
saver_instances = []
for instance in context:
if instance.data["productType"] != "render":
for inst in context:
if inst.data["productType"] != "render":
# Allow only saver family instances
continue
if not instance.data.get("publish", True):
if not inst.data.get("publish", True):
# Skip inactive instances
continue
self.log.debug(instance.data["name"])
saver_instances.append(instance)
self.log.debug(inst.data["name"])
saver_instances.append(inst)
if not saver_instances:
raise RuntimeError("No instances found for Deadline submission")

View file

@ -13,7 +13,7 @@ class LoaderAddon(AYONAddon, ITrayAddon):
# Add library tool
self._loader_imported = False
try:
from ayon_core.tools.loader.ui import LoaderWindow
from ayon_core.tools.loader.ui import LoaderWindow # noqa F401
self._loader_imported = True
except Exception:

View file

@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
"""Wrapper around Royal Render API."""
import sys
import os
import sys
from ayon_core.lib.local_settings import AYONSettingsRegistry
from ayon_core.lib import Logger, run_subprocess
from .rr_job import RRJob, SubmitFile, SubmitterParameter
from ayon_core.lib import Logger, run_subprocess, AYONSettingsRegistry
from ayon_core.lib.vendor_bin_utils import find_tool_in_custom_paths
from .rr_job import SubmitFile
from .rr_job import RRjob, SubmitterParameter # noqa F401
class Api:

View file

@ -3,7 +3,6 @@
import os
import attr
import json
import re
import pyblish.api

View file

@ -549,7 +549,7 @@ class Anatomy(BaseAnatomy):
)
else:
# Ask sync server to get roots overrides
roots_overrides = sitesync.get_site_root_overrides(
roots_overrides = sitesync_addon.get_site_root_overrides(
project_name, site_name
)
site_cache.update_data(roots_overrides)

View file

@ -14,7 +14,6 @@ from .exceptions import (
TemplateMissingKey,
AnatomyTemplateUnsolved,
)
from .roots import RootItem
_PLACEHOLDER = object()

View file

@ -1,7 +1,6 @@
"""Core pipeline functionality"""
import os
import types
import logging
import platform
import uuid
@ -21,7 +20,6 @@ from .anatomy import Anatomy
from .template_data import get_template_data_with_names
from .workfile import (
get_workdir,
get_workfile_template_key,
get_custom_workfile_template_by_string_context,
)
from . import (

View file

@ -1790,10 +1790,10 @@ class CreateContext:
creator_identifier = creator_class.identifier
if creator_identifier in creators:
self.log.warning((
"Duplicated Creator identifier. "
"Using first and skipping following"
))
self.log.warning(
"Duplicate Creator identifier: '%s'. Using first Creator "
"and skipping: %s", creator_identifier, creator_class
)
continue
# Filter by host name

View file

@ -6,13 +6,11 @@ from copy import deepcopy
import attr
import ayon_api
import pyblish.api
import clique
from ayon_core.pipeline import (
get_current_project_name,
get_representation_path,
Anatomy,
)
from ayon_core.lib import Logger
from ayon_core.pipeline.publish import KnownPublishError
@ -137,7 +135,7 @@ def get_transferable_representations(instance):
list of dicts: List of transferable representations.
"""
anatomy = instance.context.data["anatomy"] # type: Anatomy
anatomy = instance.context.data["anatomy"]
to_transfer = []
for representation in instance.data.get("representations", []):
@ -166,7 +164,6 @@ def get_transferable_representations(instance):
def create_skeleton_instance(
instance, families_transfer=None, instance_transfer=None):
# type: (pyblish.api.Instance, list, dict) -> dict
"""Create skeleton instance from original instance data.
This will create dictionary containing skeleton
@ -191,7 +188,7 @@ def create_skeleton_instance(
context = instance.context
data = instance.data.copy()
anatomy = instance.context.data["anatomy"] # type: Anatomy
anatomy = instance.context.data["anatomy"]
# get time related data from instance (or context)
time_data = get_time_data_from_instance_or_context(instance)
@ -620,15 +617,32 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
aov_patterns = aov_filter
preview = match_aov_pattern(app, aov_patterns, render_file_name)
# toggle preview on if multipart is on
if instance.data.get("multipartExr"):
log.debug("Adding preview tag because its multipartExr")
preview = True
new_instance = deepcopy(skeleton)
new_instance["productName"] = product_name
new_instance["productGroup"] = group_name
# toggle preview on if multipart is on
# Because we cant query the multipartExr data member of each AOV we'll
# need to have hardcoded rule of excluding any renders with
# "cryptomatte" in the file name from being a multipart EXR. This issue
# happens with Redshift that forces Cryptomatte renders to be separate
# files even when the rest of the AOVs are merged into a single EXR.
# There might be an edge case where the main instance has cryptomatte
# in the name even though it's a multipart EXR.
if instance.data.get("renderer") == "redshift":
if (
instance.data.get("multipartExr") and
"cryptomatte" not in render_file_name.lower()
):
log.debug("Adding preview tag because it's multipartExr")
preview = True
else:
new_instance["multipartExr"] = False
elif instance.data.get("multipartExr"):
log.debug("Adding preview tag because its multipartExr")
preview = True
# explicitly disable review by user
preview = preview and not do_not_add_review
if preview:
@ -751,7 +765,6 @@ def get_resources(project_name, version_entity, extension=None):
def create_skeleton_instance_cache(instance):
# type: (pyblish.api.Instance, list, dict) -> dict
"""Create skeleton instance from original instance data.
This will create dictionary containing skeleton
@ -771,7 +784,7 @@ def create_skeleton_instance_cache(instance):
context = instance.context
data = instance.data.copy()
anatomy = instance.context.data["anatomy"] # type: Anatomy
anatomy = instance.context.data["anatomy"]
# get time related data from instance (or context)
time_data = get_time_data_from_instance_or_context(instance)
@ -1005,7 +1018,7 @@ def copy_extend_frames(instance, representation):
start = instance.data.get("frameStart")
end = instance.data.get("frameEnd")
project_name = instance.context.data["project"]
anatomy = instance.context.data["anatomy"] # type: Anatomy
anatomy = instance.context.data["anatomy"]
folder_entity = ayon_api.get_folder_by_path(
project_name, instance.data.get("folderPath")

View file

@ -81,6 +81,9 @@ class RenderInstance(object):
outputDir = attr.ib(default=None)
context = attr.ib(default=None)
# The source instance the data of this render instance should merge into
source_instance = attr.ib(default=None, type=pyblish.api.Instance)
@frameStart.validator
def check_frame_start(self, _, value):
"""Validate if frame start is not larger then end."""
@ -214,8 +217,11 @@ class AbstractCollectRender(pyblish.api.ContextPlugin):
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
# Merge into source instance if provided, otherwise create instance
instance = render_instance_dict.pop("source_instance", None)
if instance is None:
instance = context.create_instance(render_instance.name)
instance.data.update(render_instance_dict)
instance.data.update(data)

View file

@ -13,7 +13,6 @@ Resources:
"""
import os
import re
import json
import logging

View file

@ -329,7 +329,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_name'
@ -375,7 +375,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -395,7 +395,7 @@ class AbstractTemplateBuilder(object):
is good practice to check if the same value is not already stored under
different key or if the key is not already used for something else.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -466,7 +466,7 @@ class AbstractTemplateBuilder(object):
return list(sorted(
placeholders,
key=lambda i: i.order
key=lambda placeholder: placeholder.order
))
def build_template(
@ -685,7 +685,7 @@ class AbstractTemplateBuilder(object):
for placeholder in placeholders
}
all_processed = len(placeholders) == 0
# Counter is checked at the ned of a loop so the loop happens at least
# Counter is checked at the end of a loop so the loop happens at least
# once.
iter_counter = 0
while not all_processed:
@ -1045,7 +1045,7 @@ class PlaceholderPlugin(object):
Using shared data from builder but stored under plugin identifier.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -1085,7 +1085,7 @@ class PlaceholderPlugin(object):
Using shared data from builder but stored under plugin identifier.
Key should be self explanatory to content.
Key should be self-explanatory to content.
- wrong: 'folder'
- good: 'folder_path'
@ -1107,10 +1107,10 @@ class PlaceholderItem(object):
"""Item representing single item in scene that is a placeholder to process.
Items are always created and updated by their plugins. Each plugin can use
modified class of 'PlacehoderItem' but only to add more options instead of
modified class of 'PlaceholderItem' but only to add more options instead of
new other.
Scene identifier is used to avoid processing of the palceholder item
Scene identifier is used to avoid processing of the placeholder item
multiple times so must be unique across whole workfile builder.
Args:
@ -1162,7 +1162,7 @@ class PlaceholderItem(object):
"""Placeholder data which can modify how placeholder is processed.
Possible general keys
- order: Can define the order in which is palceholder processed.
- order: Can define the order in which is placeholder processed.
Lower == earlier.
Other keys are defined by placeholder and should validate them on item
@ -1264,11 +1264,9 @@ class PlaceholderLoadMixin(object):
"""Unified attribute definitions for load placeholder.
Common function for placeholder plugins used for loading of
repsentations. Use it in 'get_placeholder_options'.
representations. Use it in 'get_placeholder_options'.
Args:
plugin (PlaceholderPlugin): Plugin used for loading of
representations.
options (Dict[str, Any]): Already available options which are used
as defaults for attributes.
@ -1468,7 +1466,9 @@ class PlaceholderLoadMixin(object):
product_name_regex = None
if product_name_regex_value:
product_name_regex = re.compile(product_name_regex_value)
product_type = placeholder.data["family"]
product_type = placeholder.data.get("product_type")
if product_type is None:
product_type = placeholder.data["family"]
builder_type = placeholder.data["builder_type"]
folder_ids = []
@ -1529,35 +1529,22 @@ class PlaceholderLoadMixin(object):
pass
def _reduce_last_version_repre_entities(self, representations):
"""Reduce representations to last verison."""
def _reduce_last_version_repre_entities(self, repre_contexts):
"""Reduce representations to last version."""
mapping = {}
# TODO use representation context with entities
# - using 'folder', 'subset' and 'version' from context on
# representation is danger
for repre_entity in representations:
repre_context = repre_entity["context"]
folder_name = repre_context["asset"]
product_name = repre_context["subset"]
version = repre_context.get("version", -1)
if folder_name not in mapping:
mapping[folder_name] = {}
product_mapping = mapping[folder_name]
if product_name not in product_mapping:
product_mapping[product_name] = collections.defaultdict(list)
version_mapping = product_mapping[product_name]
version_mapping[version].append(repre_entity)
version_mapping_by_product_id = {}
for repre_context in repre_contexts:
product_id = repre_context["product"]["id"]
version = repre_context["version"]["version"]
version_mapping = version_mapping_by_product_id.setdefault(
product_id, {}
)
version_mapping.setdefault(version, []).append(repre_context)
output = []
for product_mapping in mapping.values():
for version_mapping in product_mapping.values():
last_version = tuple(sorted(version_mapping.keys()))[-1]
output.extend(version_mapping[last_version])
for version_mapping in version_mapping_by_product_id.values():
last_version = max(version_mapping.keys())
output.extend(version_mapping[last_version])
return output
def populate_load_placeholder(self, placeholder, ignore_repre_ids=None):
@ -1585,32 +1572,33 @@ class PlaceholderLoadMixin(object):
loader_name = placeholder.data["loader"]
loader_args = self.parse_loader_args(placeholder.data["loader_args"])
placeholder_representations = self._get_representations(placeholder)
placeholder_representations = [
repre_entity
for repre_entity in self._get_representations(placeholder)
if repre_entity["id"] not in ignore_repre_ids
]
filtered_representations = []
for representation in self._reduce_last_version_repre_entities(
placeholder_representations
):
repre_id = representation["id"]
if repre_id not in ignore_repre_ids:
filtered_representations.append(representation)
if not filtered_representations:
repre_load_contexts = get_representation_contexts(
self.project_name, placeholder_representations
)
filtered_repre_contexts = self._reduce_last_version_repre_entities(
repre_load_contexts.values()
)
if not filtered_repre_contexts:
self.log.info((
"There's no representation for this placeholder: {}"
).format(placeholder.scene_identifier))
if not placeholder.data.get("keep_placeholder", True):
self.delete_placeholder(placeholder)
return
repre_load_contexts = get_representation_contexts(
self.project_name, filtered_representations
)
loaders_by_name = self.builder.get_loaders_by_name()
self._before_placeholder_load(
placeholder
)
failed = False
for repre_load_context in repre_load_contexts.values():
for repre_load_context in filtered_repre_contexts:
folder_path = repre_load_context["folder"]["path"]
product_name = repre_load_context["product"]["name"]
representation = repre_load_context["representation"]
@ -1695,8 +1683,6 @@ class PlaceholderCreateMixin(object):
publishable instances. Use it with 'get_placeholder_options'.
Args:
plugin (PlaceholderPlugin): Plugin used for creating of
publish instances.
options (Dict[str, Any]): Already available options which are used
as defaults for attributes.

View file

@ -3,8 +3,6 @@ import platform
import subprocess
from string import Formatter
import ayon_api
from ayon_core.pipeline import (
Anatomy,
LauncherAction,

View file

@ -82,20 +82,6 @@ class BaseObj:
def main_style(self):
return load_default_style()
def height(self):
raise NotImplementedError(
"Attribute `height` is not implemented for <{}>".format(
self.__clas__.__name__
)
)
def width(self):
raise NotImplementedError(
"Attribute `width` is not implemented for <{}>".format(
self.__clas__.__name__
)
)
def collect_data(self):
return None

View file

@ -284,7 +284,13 @@ class ProductsModel(QtGui.QStandardItemModel):
model_item.setData(label, QtCore.Qt.DisplayRole)
return model_item
def _set_version_data_to_product_item(self, model_item, version_item):
def _set_version_data_to_product_item(
self,
model_item,
version_item,
repre_count_by_version_id=None,
sync_availability_by_version_id=None,
):
"""
Args:
@ -292,6 +298,10 @@ class ProductsModel(QtGui.QStandardItemModel):
from version item.
version_item (VersionItem): Item from entities model with
information about version.
repre_count_by_version_id (Optional[str, int]): Mapping of
representation count by version id.
sync_availability_by_version_id (Optional[str, Tuple[int, int]]):
Mapping of sync availability by version id.
"""
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
@ -312,12 +322,20 @@ class ProductsModel(QtGui.QStandardItemModel):
# TODO call site sync methods for all versions at once
project_name = self._last_project_name
version_id = version_item.version_id
repre_count = self._controller.get_versions_representation_count(
project_name, [version_id]
)[version_id]
active, remote = self._controller.get_version_sync_availability(
project_name, [version_id]
)[version_id]
if repre_count_by_version_id is None:
repre_count_by_version_id = (
self._controller.get_versions_representation_count(
project_name, [version_id]
)
)
if sync_availability_by_version_id is None:
sync_availability_by_version_id = (
self._controller.get_version_sync_availability(
project_name, [version_id]
)
)
repre_count = repre_count_by_version_id[version_id]
active, remote = sync_availability_by_version_id[version_id]
model_item.setData(repre_count, REPRESENTATIONS_COUNT_ROLE)
model_item.setData(active, SYNC_ACTIVE_SITE_AVAILABILITY)
@ -327,7 +345,9 @@ class ProductsModel(QtGui.QStandardItemModel):
self,
product_item,
active_site_icon,
remote_site_icon
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
):
model_item = self._items_by_id.get(product_item.product_id)
versions = list(product_item.version_items.values())
@ -357,7 +377,12 @@ class ProductsModel(QtGui.QStandardItemModel):
model_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE)
model_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE)
self._set_version_data_to_product_item(model_item, last_version)
self._set_version_data_to_product_item(
model_item,
last_version,
repre_count_by_version_id,
sync_availability_by_version_id,
)
return model_item
def get_last_project_name(self):
@ -387,6 +412,24 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item.product_id: product_item
for product_item in product_items
}
last_version_id_by_product_id = {}
for product_item in product_items:
versions = list(product_item.version_items.values())
versions.sort()
last_version = versions[-1]
last_version_id_by_product_id[product_item.product_id] = (
last_version.version_id
)
version_ids = set(last_version_id_by_product_id.values())
repre_count_by_version_id = self._controller.get_versions_representation_count(
project_name, version_ids
)
sync_availability_by_version_id = (
self._controller.get_version_sync_availability(
project_name, version_ids
)
)
# Prepare product groups
product_name_matches_by_group = collections.defaultdict(dict)
@ -443,6 +486,8 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item,
active_site_icon,
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
)
new_items.append(item)
@ -463,6 +508,8 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item,
active_site_icon,
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
)
new_merged_items.append(item)
merged_product_types.add(product_item.product_type)

View file

@ -343,8 +343,9 @@ class QtRemotePublishController(BasePublisherController):
@abstractmethod
def _send_instance_changes_to_client(self):
instance_changes = self._get_instance_changes_for_client()
# Implement to send 'instance_changes' value to client
# TODO Implement to send 'instance_changes' value to client
# instance_changes = self._get_instance_changes_for_client()
pass
@abstractmethod
def save_changes(self):

View file

@ -552,7 +552,7 @@ class TrayStarter(QtCore.QObject):
def main():
app = get_ayon_qt_app()
starter = TrayStarter(app)
starter = TrayStarter(app) # noqa F841
if not is_running_from_build() and os.name == "nt":
import ctypes

View file

@ -562,11 +562,11 @@ class HSLInputs(QtWidgets.QWidget):
return
self._block_changes = True
h, s, l, _ = self.color.getHsl()
hue, sat, lum, _ = self.color.getHsl()
self.input_hue.setValue(h)
self.input_sat.setValue(s)
self.input_light.setValue(l)
self.input_hue.setValue(hue)
self.input_sat.setValue(sat)
self.input_light.setValue(lum)
self._block_changes = False

View file

@ -578,7 +578,8 @@ class OptionalAction(QtWidgets.QWidgetAction):
def set_option_tip(self, options):
sep = "\n\n"
if not options or not isinstance(options[0], AbstractAttrDef):
mak = (lambda opt: opt["name"] + " :\n " + opt["help"])
def mak(opt):
return opt["name"] + " :\n " + opt["help"]
self.option_tip = sep.join(mak(opt) for opt in options)
return

View file

@ -8,12 +8,12 @@ from ayon_core.tools.utils.dialogs import show_message_dialog
def open_template_ui(builder, main_window):
"""Open template from `builder`
Asks user about overwriting current scene and feedsback exceptions.
Asks user about overwriting current scene and feedback exceptions.
"""
result = QtWidgets.QMessageBox.question(
main_window,
"Opening template",
"Caution! You will loose unsaved changes.\nDo you want to continue?",
"Caution! You will lose unsaved changes.\nDo you want to continue?",
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No
)
if result == QtWidgets.QMessageBox.Yes:

View file

@ -77,6 +77,20 @@ unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
exclude = [
"client/ayon_core/hosts/unreal/integration/*",
"client/ayon_core/hosts/aftereffects/api/extension/js/libs/*",
"client/ayon_core/hosts/hiero/api/startup/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/CelAction/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/HarmonyAYON/*",
"client/ayon_core/modules/click_wrap.py",
"client/ayon_core/scripts/slates/__init__.py"
]
[tool.ruff.lint.per-file-ignores]
"client/ayon_core/lib/__init__.py" = ["E402"]
"client/ayon_core/hosts/max/startup/startup.py" = ["E402"]
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"

View file

@ -31,6 +31,7 @@ from .addon import ApplicationsAddon
__all__ = (
"APPLICATIONS_ADDON_ROOT",
"DEFAULT_ENV_SUBGROUP",
"PLATFORM_NAMES",

View file

@ -0,0 +1,3 @@
name = "applications"
title = "Applications"
version = "0.2.0"

View file

@ -6,7 +6,6 @@ from ayon_server.addons import BaseServerAddon, AddonLibrary
from ayon_server.entities.core import attribute_library
from ayon_server.lib.postgres import Postgres
from .version import __version__
from .settings import ApplicationsAddonSettings, DEFAULT_VALUES
try:
@ -87,9 +86,6 @@ def get_enum_items_from_groups(groups):
class ApplicationsAddon(BaseServerAddon):
name = "applications"
title = "Applications"
version = __version__
settings_model = ApplicationsAddonSettings
async def get_default_settings(self):

View file

@ -1 +0,0 @@
__version__ = "0.1.9"

View file

@ -4,6 +4,8 @@ import re
import shutil
import argparse
import zipfile
import types
import importlib
import platform
import collections
from pathlib import Path
@ -44,6 +46,11 @@ version = "{addon_version}"
plugin_for = ["ayon_server"]
"""
CLIENT_VERSION_CONTENT = '''# -*- coding: utf-8 -*-
"""Package declaring AYON core addon version."""
__version__ = "{}"
'''
class ZipFileLongPaths(zipfile.ZipFile):
"""Allows longer paths in zip files.
@ -175,13 +182,75 @@ def create_addon_zip(
shutil.rmtree(str(output_dir / addon_name))
def prepare_client_code(
addon_dir: Path,
addon_output_dir: Path,
addon_version: str
):
client_dir = addon_dir / "client"
if not client_dir.exists():
return
# Prepare private dir in output
private_dir = addon_output_dir / "private"
private_dir.mkdir(parents=True, exist_ok=True)
# Copy pyproject toml if available
pyproject_toml = client_dir / "pyproject.toml"
if pyproject_toml.exists():
shutil.copy(pyproject_toml, private_dir)
for subpath in client_dir.iterdir():
if subpath.name == "pyproject.toml":
continue
if subpath.is_file():
continue
# Update version.py with server version if 'version.py' is available
version_path = subpath / "version.py"
if version_path.exists():
with open(version_path, "w") as stream:
stream.write(CLIENT_VERSION_CONTENT.format(addon_version))
zip_filepath = private_dir / "client.zip"
with ZipFileLongPaths(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zipf:
# Add client code content to zip
for path, sub_path in find_files_in_subdir(str(subpath)):
sub_path = os.path.join(subpath.name, sub_path)
zipf.write(path, sub_path)
def import_filepath(path: Path, module_name: Optional[str] = None):
if not module_name:
module_name = os.path.splitext(path.name)[0]
# Convert to string
path = str(path)
module = types.ModuleType(module_name)
module.__file__ = path
# Use loader so module has full specs
module_loader = importlib.machinery.SourceFileLoader(
module_name, path
)
module_loader.exec_module(module)
return module
def create_addon_package(
addon_dir: Path,
output_dir: Path,
create_zip: bool,
keep_source: bool,
):
addon_version = get_addon_version(addon_dir)
src_package_py = addon_dir / "package.py"
package = None
if src_package_py.exists():
package = import_filepath(src_package_py)
addon_version = package.version
else:
addon_version = get_addon_version(addon_dir)
addon_output_dir = output_dir / addon_dir.name / addon_version
if addon_output_dir.exists():
@ -189,22 +258,27 @@ def create_addon_package(
addon_output_dir.mkdir(parents=True)
# Copy server content
package_py = addon_output_dir / "package.py"
addon_name = addon_dir.name
if addon_name == "royal_render":
addon_name = "royalrender"
package_py_content = PACKAGE_PY_TEMPLATE.format(
addon_name=addon_name, addon_version=addon_version
)
dst_package_py = addon_output_dir / "package.py"
if package is not None:
shutil.copy(src_package_py, dst_package_py)
else:
addon_name = addon_dir.name
if addon_name == "royal_render":
addon_name = "royalrender"
package_py_content = PACKAGE_PY_TEMPLATE.format(
addon_name=addon_name, addon_version=addon_version
)
with open(package_py, "w+") as pkg_py:
pkg_py.write(package_py_content)
with open(dst_package_py, "w+") as pkg_py:
pkg_py.write(package_py_content)
server_dir = addon_dir / "server"
shutil.copytree(
server_dir, addon_output_dir / "server", dirs_exist_ok=True
)
prepare_client_code(addon_dir, addon_output_dir, addon_version)
if create_zip:
create_addon_zip(
output_dir, addon_dir.name, addon_version, keep_source

View file

@ -1,3 +1,4 @@
from typing import TYPE_CHECKING
from pydantic import validator
from ayon_server.settings import (
@ -5,6 +6,8 @@ from ayon_server.settings import (
SettingsField,
ensure_unique_names,
)
if TYPE_CHECKING:
from ayon_server.addons import BaseServerAddon
from .publish_plugins import (
PublishPluginsModel,

View file

@ -1,5 +1,5 @@
from ayon_server.settings import BaseSettingsModel, SettingsField
from ayon_server.types import ColorRGB_float, ColorRGBA_uint8
from ayon_server.types import ColorRGBA_uint8
class LoaderEnabledModel(BaseSettingsModel):

View file

@ -6,7 +6,7 @@ from ayon_server.settings import (
ensure_unique_names,
task_types_enum,
)
from ayon_server.types import ColorRGBA_uint8, ColorRGB_float
from ayon_server.types import ColorRGBA_uint8
def hardware_falloff_enum():

View file

@ -1,5 +1,5 @@
from ayon_server.settings import BaseSettingsModel, SettingsField
from ayon_server.types import ColorRGBA_uint8, ColorRGB_uint8
from ayon_server.types import ColorRGBA_uint8
class CollectRenderInstancesModel(BaseSettingsModel):