Merge branch 'develop' into enhancement/fusion_validate_instance_in_context

This commit is contained in:
Roy Nieterau 2024-04-17 22:03:57 +02:00 committed by GitHub
commit d83d0702b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 468 additions and 138 deletions

View file

@ -50,7 +50,7 @@ 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(2, 0, 0),
"applications": VersionInfo(0, 2, 0),
}
# Inherit from `object` for Python 2 hosts

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
@ -149,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(
@ -172,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
@ -202,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)
@ -225,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

@ -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

@ -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,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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -617,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:

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

@ -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

@ -22,7 +22,7 @@ class ServerListSubmodel(BaseSettingsModel):
async def defined_deadline_ws_name_enum_resolver(
addon: BaseServerAddon,
addon: "BaseServerAddon",
settings_variant: str = "production",
project_name: str | None = None,
) -> list[str]: