mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Merge branch 'develop' into enhancement/fusion_validate_instance_in_context
This commit is contained in:
commit
d83d0702b0
24 changed files with 468 additions and 138 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue