diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 9fb7bbc66c..f74904f79d 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,9 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.17.2-nightly.4
+ - 3.17.2-nightly.3
+ - 3.17.2-nightly.2
- 3.17.2-nightly.1
- 3.17.1
- 3.17.1-nightly.3
@@ -132,9 +135,6 @@ body:
- 3.14.11-nightly.1
- 3.14.10
- 3.14.10-nightly.9
- - 3.14.10-nightly.8
- - 3.14.10-nightly.7
- - 3.14.10-nightly.6
validations:
required: true
- type: dropdown
diff --git a/openpype/cli.py b/openpype/cli.py
index 0df277fb0a..7422f32f13 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -290,11 +290,15 @@ def run(script):
"--setup_only",
help="Only create dbs, do not run tests",
default=None)
+@click.option("--mongo_url",
+ help="MongoDB for testing.",
+ default=None)
def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant,
- timeout, setup_only):
+ timeout, setup_only, mongo_url):
"""Run all automatic tests after proper initialization via start.py"""
PypeCommands().run_tests(folder, mark, pyargs, test_data_folder,
- persist, app_variant, timeout, setup_only)
+ persist, app_variant, timeout, setup_only,
+ mongo_url)
@main.command(help="DEPRECATED - run sync server")
diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py
index eeb55784e1..5b38405c34 100644
--- a/openpype/client/server/operations.py
+++ b/openpype/client/server/operations.py
@@ -422,7 +422,7 @@ def failed_json_default(value):
class ServerCreateOperation(CreateOperation):
- """Opeartion to create an entity.
+ """Operation to create an entity.
Args:
project_name (str): On which project operation will happen.
@@ -634,7 +634,7 @@ class ServerUpdateOperation(UpdateOperation):
class ServerDeleteOperation(DeleteOperation):
- """Opeartion to delete an entity.
+ """Operation to delete an entity.
Args:
project_name (str): On which project operation will happen.
@@ -647,7 +647,7 @@ class ServerDeleteOperation(DeleteOperation):
self._session = session
if entity_type == "asset":
- entity_type == "folder"
+ entity_type = "folder"
elif entity_type == "hero_version":
entity_type = "version"
diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_new_console_apps.py
similarity index 82%
rename from openpype/hooks/pre_foundry_apps.py
rename to openpype/hooks/pre_new_console_apps.py
index 7536df4c16..9727b4fb78 100644
--- a/openpype/hooks/pre_foundry_apps.py
+++ b/openpype/hooks/pre_new_console_apps.py
@@ -2,7 +2,7 @@ import subprocess
from openpype.lib.applications import PreLaunchHook, LaunchTypes
-class LaunchFoundryAppsWindows(PreLaunchHook):
+class LaunchNewConsoleApps(PreLaunchHook):
"""Foundry applications have specific way how to launch them.
Nuke is executed "like" python process so it is required to pass
@@ -13,13 +13,15 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
# Should be as last hook because must change launch arguments to string
order = 1000
- app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"}
+ app_groups = {
+ "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy"
+ }
platforms = {"windows"}
launch_types = {LaunchTypes.local}
def execute(self):
# Change `creationflags` to CREATE_NEW_CONSOLE
- # - on Windows nuke will create new window using its console
+ # - on Windows some apps will create new window using its console
# Set `stdout` and `stderr` to None so new created console does not
# have redirected output to DEVNULL in build
self.launch_context.kwargs.update({
diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py
index 777e383215..2aa3a5e49a 100644
--- a/openpype/hosts/blender/hooks/pre_pyside_install.py
+++ b/openpype/hosts/blender/hooks/pre_pyside_install.py
@@ -31,7 +31,7 @@ class InstallPySideToBlender(PreLaunchHook):
def inner_execute(self):
# Get blender's python directory
- version_regex = re.compile(r"^[2-3]\.[0-9]+$")
+ version_regex = re.compile(r"^[2-4]\.[0-9]+$")
platform = system().lower()
executable = self.launch_context.executable.executable_path
diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py
index 292925c833..9b3d940536 100644
--- a/openpype/hosts/blender/plugins/load/load_abc.py
+++ b/openpype/hosts/blender/plugins/load/load_abc.py
@@ -26,8 +26,7 @@ class CacheModelLoader(plugin.AssetLoader):
Note:
At least for now it only supports Alembic files.
"""
-
- families = ["model", "pointcache"]
+ families = ["model", "pointcache", "animation"]
representations = ["abc"]
label = "Load Alembic"
@@ -53,16 +52,12 @@ class CacheModelLoader(plugin.AssetLoader):
def _process(self, libpath, asset_group, group_name):
plugin.deselect_all()
- collection = bpy.context.view_layer.active_layer_collection.collection
-
relative = bpy.context.preferences.filepaths.use_relative_paths
bpy.ops.wm.alembic_import(
filepath=libpath,
relative_path=relative
)
- parent = bpy.context.scene.collection
-
imported = lib.get_selection()
# Children must be linked before parents,
@@ -79,6 +74,10 @@ class CacheModelLoader(plugin.AssetLoader):
objects.reverse()
for obj in objects:
+ # Unlink the object from all collections
+ collections = obj.users_collection
+ for collection in collections:
+ collection.objects.unlink(obj)
name = obj.name
obj.name = f"{group_name}:{name}"
if obj.type != 'EMPTY':
@@ -90,7 +89,7 @@ class CacheModelLoader(plugin.AssetLoader):
material_slot.material.name = f"{group_name}:{name_mat}"
if not obj.get(AVALON_PROPERTY):
- obj[AVALON_PROPERTY] = dict()
+ obj[AVALON_PROPERTY] = {}
avalon_info = obj[AVALON_PROPERTY]
avalon_info.update({"container_name": group_name})
@@ -99,6 +98,18 @@ class CacheModelLoader(plugin.AssetLoader):
return objects
+ def _link_objects(self, objects, collection, containers, asset_group):
+ # Link the imported objects to any collection where the asset group is
+ # linked to, except the AVALON_CONTAINERS collection
+ group_collections = [
+ collection
+ for collection in asset_group.users_collection
+ if collection != containers]
+
+ for obj in objects:
+ for collection in group_collections:
+ collection.objects.link(obj)
+
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
@@ -120,18 +131,21 @@ class CacheModelLoader(plugin.AssetLoader):
group_name = plugin.asset_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
- avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS)
- if not avalon_containers:
- avalon_containers = bpy.data.collections.new(
- name=AVALON_CONTAINERS)
- bpy.context.scene.collection.children.link(avalon_containers)
+ containers = bpy.data.collections.get(AVALON_CONTAINERS)
+ if not containers:
+ containers = bpy.data.collections.new(name=AVALON_CONTAINERS)
+ bpy.context.scene.collection.children.link(containers)
asset_group = bpy.data.objects.new(group_name, object_data=None)
- avalon_containers.objects.link(asset_group)
+ containers.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)
- bpy.context.scene.collection.objects.link(asset_group)
+ # Link the asset group to the active collection
+ collection = bpy.context.view_layer.active_layer_collection.collection
+ collection.objects.link(asset_group)
+
+ self._link_objects(objects, asset_group, containers, asset_group)
asset_group[AVALON_PROPERTY] = {
"schema": "openpype:container-2.0",
@@ -207,7 +221,11 @@ class CacheModelLoader(plugin.AssetLoader):
mat = asset_group.matrix_basis.copy()
self._remove(asset_group)
- self._process(str(libpath), asset_group, object_name)
+ objects = self._process(str(libpath), asset_group, object_name)
+
+ containers = bpy.data.collections.get(AVALON_CONTAINERS)
+ self._link_objects(objects, asset_group, containers, asset_group)
+
asset_group.matrix_basis = mat
metadata["libpath"] = str(libpath)
diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py
index fa41f4374b..25d6568889 100644
--- a/openpype/hosts/blender/plugins/load/load_blend.py
+++ b/openpype/hosts/blender/plugins/load/load_blend.py
@@ -244,7 +244,7 @@ class BlendLoader(plugin.AssetLoader):
for parent in parent_containers:
parent.get(AVALON_PROPERTY)["members"] = list(filter(
lambda i: i not in members,
- parent.get(AVALON_PROPERTY)["members"]))
+ parent.get(AVALON_PROPERTY).get("members", [])))
for attr in attrs:
for data in getattr(bpy.data, attr):
diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py
index 39edca4de3..fccd8b2965 100644
--- a/openpype/hosts/fusion/plugins/create/create_saver.py
+++ b/openpype/hosts/fusion/plugins/create/create_saver.py
@@ -123,6 +123,9 @@ class CreateSaver(NewCreator):
def _imprint(self, tool, data):
# Save all data in a "openpype.{key}" = value data
+ # Instance id is the tool's name so we don't need to imprint as data
+ data.pop("instance_id", None)
+
active = data.pop("active", None)
if active is not None:
# Use active value to set the passthrough state
@@ -188,6 +191,10 @@ class CreateSaver(NewCreator):
passthrough = attrs["TOOLB_PassThrough"]
data["active"] = not passthrough
+ # Override publisher's UUID generation because tool names are
+ # already unique in Fusion in a comp
+ data["instance_id"] = tool.Name
+
return data
def get_pre_create_attr_defs(self):
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index a3f691e1fc..3db18ca69a 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import sys
import os
+import errno
import re
import uuid
import logging
@@ -9,10 +10,15 @@ import json
import six
+from openpype.lib import StringTemplate
from openpype.client import get_asset_by_name
+from openpype.settings import get_current_project_settings
from openpype.pipeline import get_current_project_name, get_current_asset_name
-from openpype.pipeline.context_tools import get_current_project_asset
-
+from openpype.pipeline.context_tools import (
+ get_current_context_template_data,
+ get_current_project_asset
+)
+from openpype.widgets import popup
import hou
@@ -160,8 +166,6 @@ def validate_fps():
if current_fps != fps:
- from openpype.widgets import popup
-
# Find main window
parent = hou.ui.mainQtWindow()
if parent is None:
@@ -747,3 +751,99 @@ def get_camera_from_container(container):
assert len(cameras) == 1, "Camera instance must have only one camera"
return cameras[0]
+
+
+def get_context_var_changes():
+ """get context var changes."""
+
+ houdini_vars_to_update = {}
+
+ project_settings = get_current_project_settings()
+ houdini_vars_settings = \
+ project_settings["houdini"]["general"]["update_houdini_var_context"]
+
+ if not houdini_vars_settings["enabled"]:
+ return houdini_vars_to_update
+
+ houdini_vars = houdini_vars_settings["houdini_vars"]
+
+ # No vars specified - nothing to do
+ if not houdini_vars:
+ return houdini_vars_to_update
+
+ # Get Template data
+ template_data = get_current_context_template_data()
+
+ # Set Houdini Vars
+ for item in houdini_vars:
+ # For consistency reasons we always force all vars to be uppercase
+ # Also remove any leading, and trailing whitespaces.
+ var = item["var"].strip().upper()
+
+ # get and resolve template in value
+ item_value = StringTemplate.format_template(
+ item["value"],
+ template_data
+ )
+
+ if var == "JOB" and item_value == "":
+ # sync $JOB to $HIP if $JOB is empty
+ item_value = os.environ["HIP"]
+
+ if item["is_directory"]:
+ item_value = item_value.replace("\\", "/")
+
+ current_value = hou.hscript("echo -n `${}`".format(var))[0]
+
+ if current_value != item_value:
+ houdini_vars_to_update[var] = (
+ current_value, item_value, item["is_directory"]
+ )
+
+ return houdini_vars_to_update
+
+
+def update_houdini_vars_context():
+ """Update asset context variables"""
+
+ for var, (_old, new, is_directory) in get_context_var_changes().items():
+ if is_directory:
+ try:
+ os.makedirs(new)
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ print(
+ "Failed to create ${} dir. Maybe due to "
+ "insufficient permissions.".format(var)
+ )
+
+ hou.hscript("set {}={}".format(var, new))
+ os.environ[var] = new
+ print("Updated ${} to {}".format(var, new))
+
+
+def update_houdini_vars_context_dialog():
+ """Show pop-up to update asset context variables"""
+ update_vars = get_context_var_changes()
+ if not update_vars:
+ # Nothing to change
+ print("Nothing to change, Houdini vars are already up to date.")
+ return
+
+ message = "\n".join(
+ "${}: {} -> {}".format(var, old or "None", new or "None")
+ for var, (old, new, _is_directory) in update_vars.items()
+ )
+
+ # TODO: Use better UI!
+ parent = hou.ui.mainQtWindow()
+ dialog = popup.Popup(parent=parent)
+ dialog.setModal(True)
+ dialog.setWindowTitle("Houdini scene has outdated asset variables")
+ dialog.setMessage(message)
+ dialog.setButtonText("Fix")
+
+ # on_show is the Fix button clicked callback
+ dialog.on_clicked.connect(update_houdini_vars_context)
+
+ dialog.show()
diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py
index 6aa65deb89..f8db45c56b 100644
--- a/openpype/hosts/houdini/api/pipeline.py
+++ b/openpype/hosts/houdini/api/pipeline.py
@@ -300,6 +300,9 @@ def on_save():
log.info("Running callback on save..")
+ # update houdini vars
+ lib.update_houdini_vars_context_dialog()
+
nodes = lib.get_id_required_nodes()
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
@@ -335,6 +338,9 @@ def on_open():
log.info("Running callback on open..")
+ # update houdini vars
+ lib.update_houdini_vars_context_dialog()
+
# Validate FPS after update_task_from_path to
# ensure it is using correct FPS for the asset
lib.validate_fps()
@@ -399,6 +405,7 @@ def _set_context_settings():
"""
lib.reset_framerange()
+ lib.update_houdini_vars_context()
def on_pyblish_instance_toggled(instance, new_value, old_value):
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index 730a627dc3..a0a7dcc2e4 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -187,13 +187,14 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
self.customize_node_look(instance_node)
instance_data["instance_node"] = instance_node.path()
+ instance_data["instance_id"] = instance_node.path()
instance = CreatedInstance(
self.family,
subset_name,
instance_data,
self)
self._add_instance_to_context(instance)
- imprint(instance_node, instance.data_to_store())
+ self.imprint(instance_node, instance.data_to_store())
return instance
except hou.Error as er:
@@ -222,25 +223,41 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
self.cache_subsets(self.collection_shared_data)
for instance in self.collection_shared_data[
"houdini_cached_subsets"].get(self.identifier, []):
+
+ node_data = read(instance)
+
+ # Node paths are always the full node path since that is unique
+ # Because it's the node's path it's not written into attributes
+ # but explicitly collected
+ node_path = instance.path()
+ node_data["instance_id"] = node_path
+ node_data["instance_node"] = node_path
+
created_instance = CreatedInstance.from_existing(
- read(instance), self
+ node_data, self
)
self._add_instance_to_context(created_instance)
def update_instances(self, update_list):
for created_inst, changes in update_list:
instance_node = hou.node(created_inst.get("instance_node"))
-
new_values = {
key: changes[key].new_value
for key in changes.changed_keys
}
- imprint(
+ self.imprint(
instance_node,
new_values,
update=True
)
+ def imprint(self, node, values, update=False):
+ # Never store instance node and instance id since that data comes
+ # from the node's path
+ values.pop("instance_node", None)
+ values.pop("instance_id", None)
+ imprint(node, values, update=update)
+
def remove_instances(self, instances):
"""Remove specified instance from the scene.
diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml
index 5818a117eb..b2e32a70f9 100644
--- a/openpype/hosts/houdini/startup/MainMenuCommon.xml
+++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml
@@ -86,6 +86,14 @@ openpype.hosts.houdini.api.lib.reset_framerange()
]]>
+
+
+
+
+
diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py
index 260241f5fc..dbb3578f08 100644
--- a/openpype/hosts/maya/api/fbx.py
+++ b/openpype/hosts/maya/api/fbx.py
@@ -6,6 +6,7 @@ from pyblish.api import Instance
from maya import cmds # noqa
import maya.mel as mel # noqa
+from openpype.hosts.maya.api.lib import maintained_selection
class FBXExtractor:
@@ -53,7 +54,6 @@ class FBXExtractor:
"bakeComplexEnd": int,
"bakeComplexStep": int,
"bakeResampleAnimation": bool,
- "animationOnly": bool,
"useSceneName": bool,
"quaternion": str, # "euler"
"shapes": bool,
@@ -63,7 +63,10 @@ class FBXExtractor:
"embeddedTextures": bool,
"inputConnections": bool,
"upAxis": str, # x, y or z,
- "triangulate": bool
+ "triangulate": bool,
+ "fileVersion": str,
+ "skeletonDefinitions": bool,
+ "referencedAssetsContent": bool
}
@property
@@ -94,7 +97,6 @@ class FBXExtractor:
"bakeComplexEnd": end_frame,
"bakeComplexStep": 1,
"bakeResampleAnimation": True,
- "animationOnly": False,
"useSceneName": False,
"quaternion": "euler",
"shapes": True,
@@ -104,7 +106,10 @@ class FBXExtractor:
"embeddedTextures": False,
"inputConnections": True,
"upAxis": "y",
- "triangulate": False
+ "triangulate": False,
+ "fileVersion": "FBX202000",
+ "skeletonDefinitions": False,
+ "referencedAssetsContent": False
}
def __init__(self, log=None):
@@ -198,5 +203,9 @@ class FBXExtractor:
path (str): Path to use for export.
"""
- cmds.select(members, r=True, noExpand=True)
- mel.eval('FBXExport -f "{}" -s'.format(path))
+ # The export requires forward slashes because we need
+ # to format it into a string in a mel expression
+ path = path.replace("\\", "/")
+ with maintained_selection():
+ cmds.select(members, r=True, noExpand=True)
+ mel.eval('FBXExport -f "{}" -s'.format(path))
diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py
index a197e5b592..510d4ecc85 100644
--- a/openpype/hosts/maya/api/lib.py
+++ b/openpype/hosts/maya/api/lib.py
@@ -183,6 +183,51 @@ def maintained_selection():
cmds.select(clear=True)
+def get_namespace(node):
+ """Return namespace of given node"""
+ node_name = node.rsplit("|", 1)[-1]
+ if ":" in node_name:
+ return node_name.rsplit(":", 1)[0]
+ else:
+ return ""
+
+
+def strip_namespace(node, namespace):
+ """Strip given namespace from node path.
+
+ The namespace will only be stripped from names
+ if it starts with that namespace. If the namespace
+ occurs within another namespace it's not removed.
+
+ Examples:
+ >>> strip_namespace("namespace:node", namespace="namespace:")
+ "node"
+ >>> strip_namespace("hello:world:node", namespace="hello:world")
+ "node"
+ >>> strip_namespace("hello:world:node", namespace="hello")
+ "world:node"
+ >>> strip_namespace("hello:world:node", namespace="world")
+ "hello:world:node"
+ >>> strip_namespace("ns:group|ns:node", namespace="ns")
+ "group|node"
+
+ Returns:
+ str: Node name without given starting namespace.
+
+ """
+
+ # Ensure namespace ends with `:`
+ if not namespace.endswith(":"):
+ namespace = "{}:".format(namespace)
+
+ # The long path for a node can also have the namespace
+ # in its parents so we need to remove it from each
+ return "|".join(
+ name[len(namespace):] if name.startswith(namespace) else name
+ for name in node.split("|")
+ )
+
+
def get_custom_namespace(custom_namespace):
"""Return unique namespace.
@@ -922,7 +967,7 @@ def no_display_layers(nodes):
@contextlib.contextmanager
-def namespaced(namespace, new=True):
+def namespaced(namespace, new=True, relative_names=None):
"""Work inside namespace during context
Args:
@@ -934,15 +979,19 @@ def namespaced(namespace, new=True):
"""
original = cmds.namespaceInfo(cur=True, absoluteName=True)
+ original_relative_names = cmds.namespace(query=True, relativeNames=True)
if new:
namespace = unique_namespace(namespace)
cmds.namespace(add=namespace)
-
+ if relative_names is not None:
+ cmds.namespace(relativeNames=relative_names)
try:
cmds.namespace(set=namespace)
yield namespace
finally:
cmds.namespace(set=original)
+ if relative_names is not None:
+ cmds.namespace(relativeNames=original_relative_names)
@contextlib.contextmanager
@@ -4100,14 +4149,19 @@ def create_rig_animation_instance(
"""
if options is None:
options = {}
-
+ name = context["representation"]["name"]
output = next((node for node in nodes if
node.endswith("out_SET")), None)
controls = next((node for node in nodes if
node.endswith("controls_SET")), None)
+ if name != "fbx":
+ assert output, "No out_SET in rig, this is a bug."
+ assert controls, "No controls_SET in rig, this is a bug."
- assert output, "No out_SET in rig, this is a bug."
- assert controls, "No controls_SET in rig, this is a bug."
+ anim_skeleton = next((node for node in nodes if
+ node.endswith("skeletonAnim_SET")), None)
+ skeleton_mesh = next((node for node in nodes if
+ node.endswith("skeletonMesh_SET")), None)
# Find the roots amongst the loaded nodes
roots = (
@@ -4119,9 +4173,7 @@ def create_rig_animation_instance(
custom_subset = options.get("animationSubsetName")
if custom_subset:
formatting_data = {
- # TODO remove 'asset_type' and replace 'asset_name' with 'asset'
- "asset_name": context['asset']['name'],
- "asset_type": context['asset']['type'],
+ "asset": context["asset"],
"subset": context['subset']['name'],
"family": (
context['subset']['data'].get('family') or
@@ -4142,10 +4194,12 @@ def create_rig_animation_instance(
host = registered_host()
create_context = CreateContext(host)
-
# Create the animation instance
+ rig_sets = [output, controls, anim_skeleton, skeleton_mesh]
+ # Remove sets that this particular rig does not have
+ rig_sets = [s for s in rig_sets if s is not None]
with maintained_selection():
- cmds.select([output, controls] + roots, noExpand=True)
+ cmds.select(rig_sets + roots, noExpand=True)
create_context.create(
creator_identifier=creator_identifier,
variant=namespace,
diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py
index 715f54686c..18a4ea0e9a 100644
--- a/openpype/hosts/maya/api/menu.py
+++ b/openpype/hosts/maya/api/menu.py
@@ -1,14 +1,13 @@
import os
import logging
+from functools import partial
from qtpy import QtWidgets, QtGui
import maya.utils
import maya.cmds as cmds
-from openpype.settings import get_project_settings
from openpype.pipeline import (
- get_current_project_name,
get_current_asset_name,
get_current_task_name
)
@@ -46,12 +45,12 @@ def get_context_label():
)
-def install():
+def install(project_settings):
if cmds.about(batch=True):
log.info("Skipping openpype.menu initialization in batch mode..")
return
- def deferred():
+ def add_menu():
pyblish_icon = host_tools.get_pyblish_icon()
parent_widget = get_main_window()
cmds.menu(
@@ -191,7 +190,7 @@ def install():
cmds.setParent(MENU_NAME, menu=True)
- def add_scripts_menu():
+ def add_scripts_menu(project_settings):
try:
import scriptsmenu.launchformaya as launchformaya
except ImportError:
@@ -201,9 +200,6 @@ def install():
)
return
- # load configuration of custom menu
- project_name = get_current_project_name()
- project_settings = get_project_settings(project_name)
config = project_settings["maya"]["scriptsmenu"]["definition"]
_menu = project_settings["maya"]["scriptsmenu"]["name"]
@@ -225,8 +221,9 @@ def install():
# so that it only gets called after Maya UI has initialized too.
# This is crucial with Maya 2020+ which initializes without UI
# first as a QCoreApplication
- maya.utils.executeDeferred(deferred)
- cmds.evalDeferred(add_scripts_menu, lowestPriority=True)
+ maya.utils.executeDeferred(add_menu)
+ cmds.evalDeferred(partial(add_scripts_menu, project_settings),
+ lowestPriority=True)
def uninstall():
diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py
index 3647ec0b6b..6b791c9665 100644
--- a/openpype/hosts/maya/api/pipeline.py
+++ b/openpype/hosts/maya/api/pipeline.py
@@ -28,8 +28,6 @@ from openpype.lib import (
from openpype.pipeline import (
legacy_io,
get_current_project_name,
- get_current_asset_name,
- get_current_task_name,
register_loader_plugin_path,
register_inventory_action_path,
register_creator_plugin_path,
@@ -97,6 +95,8 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
self.log.info("Installing callbacks ... ")
register_event_callback("init", on_init)
+ _set_project()
+
if lib.IS_HEADLESS:
self.log.info((
"Running in headless mode, skipping Maya save/open/new"
@@ -105,10 +105,9 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
return
- _set_project()
self._register_callbacks()
- menu.install()
+ menu.install(project_settings)
register_event_callback("save", on_save)
register_event_callback("open", on_open)
diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py
index 79fcf9bc8b..3b54954c8a 100644
--- a/openpype/hosts/maya/api/plugin.py
+++ b/openpype/hosts/maya/api/plugin.py
@@ -151,6 +151,7 @@ class MayaCreatorBase(object):
# We never store the instance_node as value on the node since
# it's the node name itself
data.pop("instance_node", None)
+ data.pop("instance_id", None)
# Don't store `families` since it's up to the creator itself
# to define the initial publish families - not a stored attribute of
@@ -227,6 +228,7 @@ class MayaCreatorBase(object):
# Explicitly re-parse the node name
node_data["instance_node"] = node
+ node_data["instance_id"] = node
# If the creator plug-in specifies
families = self.get_publish_families()
@@ -601,6 +603,13 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase):
class Loader(LoaderPlugin):
hosts = ["maya"]
+ load_settings = {} # defined in settings
+
+ @classmethod
+ def apply_settings(cls, project_settings, system_settings):
+ super(Loader, cls).apply_settings(project_settings, system_settings)
+ cls.load_settings = project_settings['maya']['load']
+
def get_custom_namespace_and_group(self, context, options, loader_key):
"""Queries Settings to get custom template for namespace and group.
@@ -613,12 +622,9 @@ class Loader(LoaderPlugin):
loader_key (str): key to get separate configuration from Settings
('reference_loader'|'import_loader')
"""
- options["attach_to_root"] = True
- asset = context['asset']
- subset = context['subset']
- settings = get_project_settings(context['project']['name'])
- custom_naming = settings['maya']['load'][loader_key]
+ options["attach_to_root"] = True
+ custom_naming = self.load_settings[loader_key]
if not custom_naming['namespace']:
raise LoadError("No namespace specified in "
@@ -627,6 +633,8 @@ class Loader(LoaderPlugin):
self.log.debug("No custom group_name, no group will be created.")
options["attach_to_root"] = False
+ asset = context['asset']
+ subset = context['subset']
formatting_data = {
"asset_name": asset['name'],
"asset_type": asset['type'],
diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py
index 0fb5af149a..6cd2c69e20 100644
--- a/openpype/hosts/maya/hooks/pre_copy_mel.py
+++ b/openpype/hosts/maya/hooks/pre_copy_mel.py
@@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook):
Hook `GlobalHostDataHook` must be executed before this hook.
"""
- app_groups = {"maya"}
+ app_groups = {"maya", "mayapy"}
launch_types = {LaunchTypes.local}
def execute(self):
diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py
index 345ab6c00d..acd5c98f89 100644
--- a/openpype/hosts/maya/plugins/create/create_rig.py
+++ b/openpype/hosts/maya/plugins/create/create_rig.py
@@ -20,6 +20,13 @@ class CreateRig(plugin.MayaCreator):
instance_node = instance.get("instance_node")
self.log.info("Creating Rig instance set up ...")
+ # TODO:change name (_controls_SET -> _rigs_SET)
controls = cmds.sets(name=subset_name + "_controls_SET", empty=True)
+ # TODO:change name (_out_SET -> _geo_SET)
pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True)
- cmds.sets([controls, pointcache], forceElement=instance_node)
+ skeleton = cmds.sets(
+ name=subset_name + "_skeletonAnim_SET", empty=True)
+ skeleton_mesh = cmds.sets(
+ name=subset_name + "_skeletonMesh_SET", empty=True)
+ cmds.sets([controls, pointcache,
+ skeleton, skeleton_mesh], forceElement=instance_node)
diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py
index 981b9ef434..0781735bc4 100644
--- a/openpype/hosts/maya/plugins/load/_load_animation.py
+++ b/openpype/hosts/maya/plugins/load/_load_animation.py
@@ -1,4 +1,46 @@
import openpype.hosts.maya.api.plugin
+import maya.cmds as cmds
+
+
+def _process_reference(file_url, name, namespace, options):
+ """Load files by referencing scene in Maya.
+
+ Args:
+ file_url (str): fileapth of the objects to be loaded
+ name (str): subset name
+ namespace (str): namespace
+ options (dict): dict of storing the param
+
+ Returns:
+ list: list of object nodes
+ """
+ from openpype.hosts.maya.api.lib import unique_namespace
+ # Get name from asset being loaded
+ # Assuming name is subset name from the animation, we split the number
+ # suffix from the name to ensure the namespace is unique
+ name = name.split("_")[0]
+ ext = file_url.split(".")[-1]
+ namespace = unique_namespace(
+ "{}_".format(name),
+ format="%03d",
+ suffix="_{}".format(ext)
+ )
+
+ attach_to_root = options.get("attach_to_root", True)
+ group_name = options["group_name"]
+
+ # no group shall be created
+ if not attach_to_root:
+ group_name = namespace
+
+ nodes = cmds.file(file_url,
+ namespace=namespace,
+ sharedReferenceFile=False,
+ groupReference=attach_to_root,
+ groupName=group_name,
+ reference=True,
+ returnNewNodes=True)
+ return nodes
class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
@@ -16,44 +58,42 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
def process_reference(self, context, name, namespace, options):
- import maya.cmds as cmds
- from openpype.hosts.maya.api.lib import unique_namespace
-
cmds.loadPlugin("AbcImport.mll", quiet=True)
- # Prevent identical alembic nodes from being shared
- # Create unique namespace for the cameras
-
- # Get name from asset being loaded
- # Assuming name is subset name from the animation, we split the number
- # suffix from the name to ensure the namespace is unique
- name = name.split("_")[0]
- namespace = unique_namespace(
- "{}_".format(name),
- format="%03d",
- suffix="_abc"
- )
-
- attach_to_root = options.get("attach_to_root", True)
- group_name = options["group_name"]
-
- # no group shall be created
- if not attach_to_root:
- group_name = namespace
-
# hero_001 (abc)
# asset_counter{optional}
path = self.filepath_from_context(context)
file_url = self.prepare_root_value(path,
context["project"]["name"])
- nodes = cmds.file(file_url,
- namespace=namespace,
- sharedReferenceFile=False,
- groupReference=attach_to_root,
- groupName=group_name,
- reference=True,
- returnNewNodes=True)
+ nodes = _process_reference(file_url, name, namespace, options)
# load colorbleed ID attribute
self[:] = nodes
return nodes
+
+
+class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
+ """Loader to reference an Fbx files"""
+
+ families = ["animation",
+ "camera"]
+ representations = ["fbx"]
+
+ label = "Reference animation"
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def process_reference(self, context, name, namespace, options):
+
+ cmds.loadPlugin("fbx4maya.mll", quiet=True)
+
+ path = self.filepath_from_context(context)
+ file_url = self.prepare_root_value(path,
+ context["project"]["name"])
+
+ nodes = _process_reference(file_url, name, namespace, options)
+
+ self[:] = nodes
+
+ return nodes
diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py
new file mode 100644
index 0000000000..aef8765e9c
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from maya import cmds # noqa
+import pyblish.api
+from openpype.pipeline import OptionalPyblishPluginMixin
+
+
+class CollectFbxAnimation(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Collect Animated Rig Data for FBX Extractor."""
+
+ order = pyblish.api.CollectorOrder + 0.2
+ label = "Collect Fbx Animation"
+ hosts = ["maya"]
+ families = ["animation"]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+ skeleton_sets = [
+ i for i in instance
+ if i.endswith("skeletonAnim_SET")
+ ]
+ if not skeleton_sets:
+ return
+
+ instance.data["families"].append("animation.fbx")
+ instance.data["animated_skeleton"] = []
+ for skeleton_set in skeleton_sets:
+ skeleton_content = cmds.sets(skeleton_set, query=True)
+ self.log.debug(
+ "Collected animated skeleton data: {}".format(
+ skeleton_content
+ ))
+ if skeleton_content:
+ instance.data["animated_skeleton"] = skeleton_content
diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py
index 36a4211af1..34ff26a8b8 100644
--- a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py
+++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py
@@ -22,7 +22,8 @@ class CollectRigSets(pyblish.api.InstancePlugin):
def process(self, instance):
# Find required sets by suffix
- searching = {"controls_SET", "out_SET"}
+ searching = {"controls_SET", "out_SET",
+ "skeletonAnim_SET", "skeletonMesh_SET"}
found = {}
for node in cmds.ls(instance, exactType="objectSet"):
for suffix in searching:
diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py
new file mode 100644
index 0000000000..31f0eca88c
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+from maya import cmds # noqa
+import pyblish.api
+
+
+class CollectSkeletonMesh(pyblish.api.InstancePlugin):
+ """Collect Static Rig Data for FBX Extractor."""
+
+ order = pyblish.api.CollectorOrder + 0.2
+ label = "Collect Skeleton Mesh"
+ hosts = ["maya"]
+ families = ["rig"]
+
+ def process(self, instance):
+ skeleton_mesh_set = instance.data["rig_sets"].get(
+ "skeletonMesh_SET")
+ if not skeleton_mesh_set:
+ self.log.debug(
+ "No skeletonMesh_SET found. "
+ "Skipping collecting of skeleton mesh..."
+ )
+ return
+
+ # Store current frame to ensure single frame export
+ frame = cmds.currentTime(query=True)
+ instance.data["frameStart"] = frame
+ instance.data["frameEnd"] = frame
+
+ instance.data["skeleton_mesh"] = []
+
+ skeleton_mesh_content = cmds.sets(
+ skeleton_mesh_set, query=True) or []
+ if not skeleton_mesh_content:
+ self.log.debug(
+ "No object nodes in skeletonMesh_SET. "
+ "Skipping collecting of skeleton mesh..."
+ )
+ return
+ instance.data["families"] += ["rig.fbx"]
+ instance.data["skeleton_mesh"] = skeleton_mesh_content
+ self.log.debug(
+ "Collected skeletonMesh_SET members: {}".format(
+ skeleton_mesh_content
+ ))
diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py
new file mode 100644
index 0000000000..8288bc9329
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+import os
+
+from maya import cmds # noqa
+import pyblish.api
+
+from openpype.pipeline import publish
+from openpype.hosts.maya.api import fbx
+from openpype.hosts.maya.api.lib import (
+ namespaced, get_namespace, strip_namespace
+)
+
+
+class ExtractFBXAnimation(publish.Extractor):
+ """Extract Rig in FBX format from Maya.
+
+ This extracts the rig in fbx with the constraints
+ and referenced asset content included.
+ This also optionally extract animated rig in fbx with
+ geometries included.
+
+ """
+ order = pyblish.api.ExtractorOrder
+ label = "Extract Animation (FBX)"
+ hosts = ["maya"]
+ families = ["animation.fbx"]
+
+ def process(self, instance):
+ # Define output path
+ staging_dir = self.staging_dir(instance)
+ filename = "{0}.fbx".format(instance.name)
+ path = os.path.join(staging_dir, filename)
+ path = path.replace("\\", "/")
+
+ fbx_exporter = fbx.FBXExtractor(log=self.log)
+ out_members = instance.data.get("animated_skeleton", [])
+ # Export
+ instance.data["constraints"] = True
+ instance.data["skeletonDefinitions"] = True
+ instance.data["referencedAssetsContent"] = True
+ fbx_exporter.set_options_from_instance(instance)
+ # Export from the rig's namespace so that the exported
+ # FBX does not include the namespace but preserves the node
+ # names as existing in the rig workfile
+ namespace = get_namespace(out_members[0])
+ relative_out_members = [
+ strip_namespace(node, namespace) for node in out_members
+ ]
+ with namespaced(
+ ":" + namespace,
+ new=False,
+ relative_names=True
+ ) as namespace:
+ fbx_exporter.export(relative_out_members, path)
+
+ representations = instance.data.setdefault("representations", [])
+ representations.append({
+ 'name': 'fbx',
+ 'ext': 'fbx',
+ 'files': filename,
+ "stagingDir": staging_dir
+ })
+
+ self.log.debug(
+ "Extracted FBX animation to: {0}".format(path))
diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py
new file mode 100644
index 0000000000..50c1fb3bde
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+import os
+
+from maya import cmds # noqa
+import pyblish.api
+
+from openpype.pipeline import publish
+from openpype.pipeline.publish import OptionalPyblishPluginMixin
+from openpype.hosts.maya.api import fbx
+
+
+class ExtractSkeletonMesh(publish.Extractor,
+ OptionalPyblishPluginMixin):
+ """Extract Rig in FBX format from Maya.
+
+ This extracts the rig in fbx with the constraints
+ and referenced asset content included.
+ This also optionally extract animated rig in fbx with
+ geometries included.
+
+ """
+ order = pyblish.api.ExtractorOrder
+ label = "Extract Skeleton Mesh"
+ hosts = ["maya"]
+ families = ["rig.fbx"]
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+ # Define output path
+ staging_dir = self.staging_dir(instance)
+ filename = "{0}.fbx".format(instance.name)
+ path = os.path.join(staging_dir, filename)
+
+ fbx_exporter = fbx.FBXExtractor(log=self.log)
+ out_set = instance.data.get("skeleton_mesh", [])
+
+ instance.data["constraints"] = True
+ instance.data["skeletonDefinitions"] = True
+
+ fbx_exporter.set_options_from_instance(instance)
+
+ # Export
+ fbx_exporter.export(out_set, path)
+
+ representations = instance.data.setdefault("representations", [])
+ representations.append({
+ 'name': 'fbx',
+ 'ext': 'fbx',
+ 'files': filename,
+ "stagingDir": staging_dir
+ })
+
+ self.log.debug("Extract FBX to: {0}".format(path))
diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py
new file mode 100644
index 0000000000..4537892d6d
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py
@@ -0,0 +1,66 @@
+import pyblish.api
+import openpype.hosts.maya.api.action
+from openpype.pipeline.publish import (
+ PublishValidationError,
+ ValidateContentsOrder
+)
+from maya import cmds
+
+
+class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin):
+ """Validate all nodes in skeletonAnim_SET are referenced"""
+
+ order = ValidateContentsOrder
+ hosts = ["maya"]
+ families = ["animation.fbx"]
+ label = "Animated Reference Rig"
+ accepted_controllers = ["transform", "locator"]
+ actions = [openpype.hosts.maya.api.action.SelectInvalidAction]
+
+ def process(self, instance):
+ animated_sets = instance.data.get("animated_skeleton", [])
+ if not animated_sets:
+ self.log.debug(
+ "No nodes found in skeletonAnim_SET. "
+ "Skipping validation of animated reference rig..."
+ )
+ return
+
+ for animated_reference in animated_sets:
+ is_referenced = cmds.referenceQuery(
+ animated_reference, isNodeReferenced=True)
+ if not bool(is_referenced):
+ raise PublishValidationError(
+ "All the content in skeletonAnim_SET"
+ " should be referenced nodes"
+ )
+ invalid_controls = self.validate_controls(animated_sets)
+ if invalid_controls:
+ raise PublishValidationError(
+ "All the content in skeletonAnim_SET"
+ " should be transforms"
+ )
+
+ @classmethod
+ def validate_controls(self, set_members):
+ """Check if the controller set contains only accepted node types.
+
+ Checks if all its set members are within the hierarchy of the root
+ Checks if the node types of the set members valid
+
+ Args:
+ set_members: list of nodes of the skeleton_anim_set
+ hierarchy: list of nodes which reside under the root node
+
+ Returns:
+ errors (list)
+ """
+
+ # Validate control types
+ invalid = []
+ set_members = cmds.ls(set_members, long=True)
+ for node in set_members:
+ if cmds.nodeType(node) not in self.accepted_controllers:
+ invalid.append(node)
+
+ return invalid
diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py
index 9f47bf7a3d..cb5c68e4ab 100644
--- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py
+++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py
@@ -30,18 +30,21 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin):
def get_invalid(cls, instance):
invalid = list()
- file_attr = cls.attribute
- if not file_attr:
+ file_attrs = cls.attribute
+ if not file_attrs:
return invalid
# Consider only valid node types to avoid "Unknown object type" warning
all_node_types = set(cmds.allNodeTypes())
- node_types = [key for key in file_attr.keys() if key in all_node_types]
+ node_types = [
+ key for key in file_attrs.keys()
+ if key in all_node_types
+ ]
for node, node_type in pairwise(cmds.ls(type=node_types,
showType=True)):
# get the filepath
- file_attr = "{}.{}".format(node, file_attr[node_type])
+ file_attr = "{}.{}".format(node, file_attrs[node_type])
filepath = cmds.getAttr(file_attr)
if filepath and not os.path.exists(filepath):
diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py
new file mode 100644
index 0000000000..91b473b250
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py
@@ -0,0 +1,117 @@
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin
+)
+from maya import cmds
+from openpype.pipeline.publish import RepairAction
+from openpype.hosts.maya.api import lib
+from openpype.hosts.maya.api.lib import reset_scene_resolution
+
+
+class ValidateResolution(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validate the render resolution setting aligned with DB"""
+
+ order = pyblish.api.ValidatorOrder
+ families = ["renderlayer"]
+ hosts = ["maya"]
+ label = "Validate Resolution"
+ actions = [RepairAction]
+ optional = True
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+ invalid = self.get_invalid_resolution(instance)
+ if invalid:
+ raise PublishValidationError(
+ "Render resolution is invalid. See log for details.",
+ description=(
+ "Wrong render resolution setting. "
+ "Please use repair button to fix it.\n\n"
+ "If current renderer is V-Ray, "
+ "make sure vraySettings node has been created."
+ )
+ )
+
+ @classmethod
+ def get_invalid_resolution(cls, instance):
+ width, height, pixelAspect = cls.get_db_resolution(instance)
+ current_renderer = instance.data["renderer"]
+ layer = instance.data["renderlayer"]
+ invalid = False
+ if current_renderer == "vray":
+ vray_node = "vraySettings"
+ if cmds.objExists(vray_node):
+ current_width = lib.get_attr_in_layer(
+ "{}.width".format(vray_node), layer=layer)
+ current_height = lib.get_attr_in_layer(
+ "{}.height".format(vray_node), layer=layer)
+ current_pixelAspect = lib.get_attr_in_layer(
+ "{}.pixelAspect".format(vray_node), layer=layer
+ )
+ else:
+ cls.log.error(
+ "Can't detect VRay resolution because there is no node "
+ "named: `{}`".format(vray_node)
+ )
+ return True
+ else:
+ current_width = lib.get_attr_in_layer(
+ "defaultResolution.width", layer=layer)
+ current_height = lib.get_attr_in_layer(
+ "defaultResolution.height", layer=layer)
+ current_pixelAspect = lib.get_attr_in_layer(
+ "defaultResolution.pixelAspect", layer=layer
+ )
+ if current_width != width or current_height != height:
+ cls.log.error(
+ "Render resolution {}x{} does not match "
+ "asset resolution {}x{}".format(
+ current_width, current_height,
+ width, height
+ ))
+ invalid = True
+ if current_pixelAspect != pixelAspect:
+ cls.log.error(
+ "Render pixel aspect {} does not match "
+ "asset pixel aspect {}".format(
+ current_pixelAspect, pixelAspect
+ ))
+ invalid = True
+ return invalid
+
+ @classmethod
+ def get_db_resolution(cls, instance):
+ asset_doc = instance.data["assetEntity"]
+ project_doc = instance.context.data["projectEntity"]
+ for data in [asset_doc["data"], project_doc["data"]]:
+ if (
+ "resolutionWidth" in data and
+ "resolutionHeight" in data and
+ "pixelAspect" in data
+ ):
+ width = data["resolutionWidth"]
+ height = data["resolutionHeight"]
+ pixelAspect = data["pixelAspect"]
+ return int(width), int(height), float(pixelAspect)
+
+ # Defaults if not found in asset document or project document
+ return 1920, 1080, 1.0
+
+ @classmethod
+ def repair(cls, instance):
+ # Usually without renderlayer overrides the renderlayers
+ # all share the same resolution value - so fixing the first
+ # will have fixed all the others too. It's much faster to
+ # check whether it's invalid first instead of switching
+ # into all layers individually
+ if not cls.get_invalid_resolution(instance):
+ cls.log.debug(
+ "Nothing to repair on instance: {}".format(instance)
+ )
+ return
+ layer_node = instance.data['setMembers']
+ with lib.renderlayer(layer_node):
+ reset_scene_resolution()
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
index 23f031a5db..106b4024e2 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py
@@ -1,6 +1,6 @@
import pyblish.api
from maya import cmds
-
+import openpype.hosts.maya.api.action
from openpype.pipeline.publish import (
PublishValidationError,
ValidateContentsOrder
@@ -20,33 +20,27 @@ class ValidateRigContents(pyblish.api.InstancePlugin):
label = "Rig Contents"
hosts = ["maya"]
families = ["rig"]
+ action = [openpype.hosts.maya.api.action.SelectInvalidAction]
accepted_output = ["mesh", "transform"]
accepted_controllers = ["transform"]
def process(self, instance):
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise PublishValidationError(
+ "Invalid rig content. See log for details.")
+
+ @classmethod
+ def get_invalid(cls, instance):
# Find required sets by suffix
- required = ["controls_SET", "out_SET"]
- missing = [
- key for key in required if key not in instance.data["rig_sets"]
- ]
- if missing:
- raise PublishValidationError(
- "%s is missing sets: %s" % (instance, ", ".join(missing))
- )
+ required, rig_sets = cls.get_nodes(instance)
- controls_set = instance.data["rig_sets"]["controls_SET"]
- out_set = instance.data["rig_sets"]["out_SET"]
+ cls.validate_missing_objectsets(instance, required, rig_sets)
- # Ensure there are at least some transforms or dag nodes
- # in the rig instance
- set_members = instance.data['setMembers']
- if not cmds.ls(set_members, type="dagNode", long=True):
- raise PublishValidationError(
- "No dag nodes in the pointcache instance. "
- "(Empty instance?)"
- )
+ controls_set = rig_sets["controls_SET"]
+ out_set = rig_sets["out_SET"]
# Ensure contents in sets and retrieve long path for all objects
output_content = cmds.sets(out_set, query=True) or []
@@ -61,49 +55,92 @@ class ValidateRigContents(pyblish.api.InstancePlugin):
)
controls_content = cmds.ls(controls_content, long=True)
- # Validate members are inside the hierarchy from root node
- root_nodes = cmds.ls(set_members, assemblies=True, long=True)
- hierarchy = cmds.listRelatives(root_nodes, allDescendents=True,
- fullPath=True) + root_nodes
- hierarchy = set(hierarchy)
-
- invalid_hierarchy = []
- for node in output_content:
- if node not in hierarchy:
- invalid_hierarchy.append(node)
- for node in controls_content:
- if node not in hierarchy:
- invalid_hierarchy.append(node)
+ rig_content = output_content + controls_content
+ invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content)
# Additional validations
- invalid_geometry = self.validate_geometry(output_content)
- invalid_controls = self.validate_controls(controls_content)
+ invalid_geometry = cls.validate_geometry(output_content)
+ invalid_controls = cls.validate_controls(controls_content)
error = False
if invalid_hierarchy:
- self.log.error("Found nodes which reside outside of root group "
+ cls.log.error("Found nodes which reside outside of root group "
"while they are set up for publishing."
"\n%s" % invalid_hierarchy)
error = True
if invalid_controls:
- self.log.error("Only transforms can be part of the controls_SET."
+ cls.log.error("Only transforms can be part of the controls_SET."
"\n%s" % invalid_controls)
error = True
if invalid_geometry:
- self.log.error("Only meshes can be part of the out_SET\n%s"
+ cls.log.error("Only meshes can be part of the out_SET\n%s"
% invalid_geometry)
error = True
-
if error:
+ return invalid_hierarchy + invalid_controls + invalid_geometry
+
+ @classmethod
+ def validate_missing_objectsets(cls, instance,
+ required_objsets, rig_sets):
+ """Validate missing objectsets in rig sets
+
+ Args:
+ instance (str): instance
+ required_objsets (list): list of objectset names
+ rig_sets (list): list of rig sets
+
+ Raises:
+ PublishValidationError: When the error is raised, it will show
+ which instance has the missing object sets
+ """
+ missing = [
+ key for key in required_objsets if key not in rig_sets
+ ]
+ if missing:
raise PublishValidationError(
- "Invalid rig content. See log for details.")
+ "%s is missing sets: %s" % (instance, ", ".join(missing))
+ )
- def validate_geometry(self, set_members):
- """Check if the out set passes the validations
+ @classmethod
+ def invalid_hierarchy(cls, instance, content):
+ """
+ Check if all rig set members are within the hierarchy of the rig root
- Checks if all its set members are within the hierarchy of the root
+ Args:
+ instance (str): instance
+ content (list): list of content from rig sets
+
+ Raises:
+ PublishValidationError: It means no dag nodes in
+ the rig instance
+
+ Returns:
+ list: invalid hierarchy
+ """
+ # Ensure there are at least some transforms or dag nodes
+ # in the rig instance
+ set_members = instance.data['setMembers']
+ if not cmds.ls(set_members, type="dagNode", long=True):
+ raise PublishValidationError(
+ "No dag nodes in the rig instance. "
+ "(Empty instance?)"
+ )
+ # Validate members are inside the hierarchy from root node
+ root_nodes = cmds.ls(set_members, assemblies=True, long=True)
+ hierarchy = cmds.listRelatives(root_nodes, allDescendents=True,
+ fullPath=True) + root_nodes
+ hierarchy = set(hierarchy)
+ invalid_hierarchy = []
+ for node in content:
+ if node not in hierarchy:
+ invalid_hierarchy.append(node)
+ return invalid_hierarchy
+
+ @classmethod
+ def validate_geometry(cls, set_members):
+ """
Checks if the node types of the set members valid
Args:
@@ -122,15 +159,13 @@ class ValidateRigContents(pyblish.api.InstancePlugin):
fullPath=True) or []
all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True)
for shape in all_shapes:
- if cmds.nodeType(shape) not in self.accepted_output:
+ if cmds.nodeType(shape) not in cls.accepted_output:
invalid.append(shape)
- return invalid
-
- def validate_controls(self, set_members):
- """Check if the controller set passes the validations
-
- Checks if all its set members are within the hierarchy of the root
+ @classmethod
+ def validate_controls(cls, set_members):
+ """
+ Checks if the control set members are allowed node types.
Checks if the node types of the set members valid
Args:
@@ -144,7 +179,80 @@ class ValidateRigContents(pyblish.api.InstancePlugin):
# Validate control types
invalid = []
for node in set_members:
- if cmds.nodeType(node) not in self.accepted_controllers:
+ if cmds.nodeType(node) not in cls.accepted_controllers:
invalid.append(node)
return invalid
+
+ @classmethod
+ def get_nodes(cls, instance):
+ """Get the target objectsets and rig sets nodes
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ tuple: 2-tuple of list of objectsets,
+ list of rig sets nodes
+ """
+ objectsets = ["controls_SET", "out_SET"]
+ rig_sets_nodes = instance.data.get("rig_sets", [])
+ return objectsets, rig_sets_nodes
+
+
+class ValidateSkeletonRigContents(ValidateRigContents):
+ """Ensure skeleton rigs contains pipeline-critical content
+
+ The rigs optionally contain at least two object sets:
+ "skeletonMesh_SET" - Set of the skinned meshes
+ with bone hierarchies
+
+ """
+
+ order = ValidateContentsOrder
+ label = "Skeleton Rig Contents"
+ hosts = ["maya"]
+ families = ["rig.fbx"]
+
+ @classmethod
+ def get_invalid(cls, instance):
+ objectsets, skeleton_mesh_nodes = cls.get_nodes(instance)
+ cls.validate_missing_objectsets(
+ instance, objectsets, instance.data["rig_sets"])
+
+ # Ensure contents in sets and retrieve long path for all objects
+ output_content = instance.data.get("skeleton_mesh", [])
+ output_content = cmds.ls(skeleton_mesh_nodes, long=True)
+
+ invalid_hierarchy = cls.invalid_hierarchy(
+ instance, output_content)
+ invalid_geometry = cls.validate_geometry(output_content)
+
+ error = False
+ if invalid_hierarchy:
+ cls.log.error("Found nodes which reside outside of root group "
+ "while they are set up for publishing."
+ "\n%s" % invalid_hierarchy)
+ error = True
+ if invalid_geometry:
+ cls.log.error("Found nodes which reside outside of root group "
+ "while they are set up for publishing."
+ "\n%s" % invalid_hierarchy)
+ error = True
+ if error:
+ return invalid_hierarchy + invalid_geometry
+
+ @classmethod
+ def get_nodes(cls, instance):
+ """Get the target objectsets and rig sets nodes
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ tuple: 2-tuple of list of objectsets,
+ list of rig sets nodes
+ """
+ objectsets = ["skeletonMesh_SET"]
+ skeleton_mesh_nodes = instance.data.get("skeleton_mesh", [])
+ return objectsets, skeleton_mesh_nodes
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
index a3828f871b..82248c57b3 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py
@@ -59,7 +59,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
- controls_set = instance.data["rig_sets"].get("controls_SET")
+ controls_set = cls.get_node(instance)
if not controls_set:
cls.log.error(
"Must have 'controls_SET' in rig instance"
@@ -189,7 +189,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin):
@classmethod
def repair(cls, instance):
- controls_set = instance.data["rig_sets"].get("controls_SET")
+ controls_set = cls.get_node(instance)
if not controls_set:
cls.log.error(
"Unable to repair because no 'controls_SET' found in rig "
@@ -228,3 +228,64 @@ class ValidateRigControllers(pyblish.api.InstancePlugin):
default = cls.CONTROLLER_DEFAULTS[attr]
cls.log.info("Setting %s to %s" % (plug, default))
cmds.setAttr(plug, default)
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from controls_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from controls_SET
+ """
+ return instance.data["rig_sets"].get("controls_SET")
+
+
+class ValidateSkeletonRigControllers(ValidateRigControllers):
+ """Validate rig controller for skeletonAnim_SET
+
+ Controls must have the transformation attributes on their default
+ values of translate zero, rotate zero and scale one when they are
+ unlocked attributes.
+
+ Unlocked keyable attributes may not have any incoming connections. If
+ these connections are required for the rig then lock the attributes.
+
+ The visibility attribute must be locked.
+
+ Note that `repair` will:
+ - Lock all visibility attributes
+ - Reset all default values for translate, rotate, scale
+ - Break all incoming connections to keyable attributes
+
+ """
+ order = ValidateContentsOrder + 0.05
+ label = "Skeleton Rig Controllers"
+ hosts = ["maya"]
+ families = ["rig.fbx"]
+
+ # Default controller values
+ CONTROLLER_DEFAULTS = {
+ "translateX": 0,
+ "translateY": 0,
+ "translateZ": 0,
+ "rotateX": 0,
+ "rotateY": 0,
+ "rotateZ": 0,
+ "scaleX": 1,
+ "scaleY": 1,
+ "scaleZ": 1
+ }
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from skeletonMesh_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from skeletonMesh_SET
+ """
+ return instance.data["rig_sets"].get("skeletonMesh_SET")
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
index fbd510c683..80ac0f27e6 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py
@@ -46,7 +46,7 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin):
def get_invalid(cls, instance):
"""Get all nodes which do not match the criteria"""
- out_set = instance.data["rig_sets"].get("out_SET")
+ out_set = cls.get_node(instance)
if not out_set:
return []
@@ -85,3 +85,45 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin):
continue
lib.set_id(node, sibling_id, overwrite=True)
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from out_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from out_SET
+ """
+ return instance.data["rig_sets"].get("out_SET")
+
+
+class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds):
+ """Validate if deformed shapes have related IDs to the original shapes
+ from skeleton set.
+
+ When a deformer is applied in the scene on a referenced mesh that already
+ had deformers then Maya will create a new shape node for the mesh that
+ does not have the original id. This validator checks whether the ids are
+ valid on all the shape nodes in the instance.
+
+ """
+
+ order = ValidateContentsOrder
+ families = ["rig.fbx"]
+ hosts = ['maya']
+ label = 'Skeleton Rig Out Set Node Ids'
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from skeletonMesh_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from skeletonMesh_SET
+ """
+ return instance.data["rig_sets"].get(
+ "skeletonMesh_SET")
diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
index 24fb36eb8b..343d8e6924 100644
--- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
+++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py
@@ -47,7 +47,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin):
invalid = {}
if compute:
- out_set = instance.data["rig_sets"].get("out_SET")
+ out_set = cls.get_node(instance)
if not out_set:
instance.data["mismatched_output_ids"] = invalid
return invalid
@@ -115,3 +115,40 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin):
"Multiple matched ids found. Please repair manually: "
"{}".format(multiple_ids_match)
)
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from out_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from out_SET
+ """
+ return instance.data["rig_sets"].get("out_SET")
+
+
+class ValidateSkeletonRigOutputIds(ValidateRigOutputIds):
+ """Validate rig output ids from the skeleton sets.
+
+ Ids must share the same id as similarly named nodes in the scene. This is
+ to ensure the id from the model is preserved through animation.
+
+ """
+ order = ValidateContentsOrder + 0.05
+ label = "Skeleton Rig Output Ids"
+ hosts = ["maya"]
+ families = ["rig.fbx"]
+
+ @classmethod
+ def get_node(cls, instance):
+ """Get target object nodes from skeletonMesh_SET
+
+ Args:
+ instance (str): instance
+
+ Returns:
+ list: list of object nodes from skeletonMesh_SET
+ """
+ return instance.data["rig_sets"].get("skeletonMesh_SET")
diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py
new file mode 100644
index 0000000000..1dbe1c454c
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+"""Plugin for validating naming conventions."""
+from maya import cmds
+
+import pyblish.api
+
+from openpype.pipeline.publish import (
+ ValidateContentsOrder,
+ OptionalPyblishPluginMixin,
+ PublishValidationError
+)
+
+
+class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validates top group hierarchy in the SETs
+ Make sure the object inside the SETs are always top
+ group of the hierarchy
+
+ """
+ order = ValidateContentsOrder + 0.05
+ label = "Skeleton Rig Top Group Hierarchy"
+ families = ["rig.fbx"]
+
+ def process(self, instance):
+ invalid = []
+ skeleton_mesh_data = instance.data("skeleton_mesh", [])
+ if skeleton_mesh_data:
+ invalid = self.get_top_hierarchy(skeleton_mesh_data)
+ if invalid:
+ raise PublishValidationError(
+ "The skeletonMesh_SET includes the object which "
+ "is not at the top hierarchy: {}".format(invalid))
+
+ def get_top_hierarchy(self, targets):
+ targets = cmds.ls(targets, long=True) # ensure long names
+ non_top_hierarchy_list = [
+ target for target in targets if target.count("|") > 2
+ ]
+ return non_top_hierarchy_list
diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py
index 5ba256f9f5..58fa9d02bd 100644
--- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py
+++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py
@@ -69,11 +69,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin,
invalid = []
- project_settings = get_project_settings(
- legacy_io.Session["AVALON_PROJECT"]
- )
collision_prefixes = (
- project_settings
+ instance.context.data["project_settings"]
["maya"]
["create"]
["CreateUnrealStaticMesh"]
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index 07f394ec00..390545b806 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -3425,34 +3425,6 @@ def create_viewer_profile_string(viewer, display=None, path_like=False):
return "{} ({})".format(viewer, display)
-def get_head_filename_without_hashes(original_path, name):
- """Function to get the renamed head filename without frame hashes
- To avoid the system being confused on finding the filename with
- frame hashes if the head of the filename has the hashed symbol
-
- Examples:
- >>> get_head_filename_without_hashes("render.####.exr", "baking")
- render.baking.####.exr
- >>> get_head_filename_without_hashes("render.%04d.exr", "tag")
- render.tag.%d.exr
- >>> get_head_filename_without_hashes("exr.####.exr", "foo")
- exr.foo.%04d.exr
-
- Args:
- original_path (str): the filename with frame hashes
- name (str): the name of the tags
-
- Returns:
- str: the renamed filename with the tag
- """
- filename = os.path.basename(original_path)
-
- def insert_name(matchobj):
- return "{}.{}".format(name, matchobj.group(0))
-
- return re.sub(r"(%\d*d)|#+", insert_name, filename)
-
-
def get_filenames_without_hash(filename, frame_start, frame_end):
"""Get filenames without frame hash
i.e. "renderCompositingMain.baking.0001.exr"
diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py
index 81841d17be..c39e3c339d 100644
--- a/openpype/hosts/nuke/api/plugin.py
+++ b/openpype/hosts/nuke/api/plugin.py
@@ -39,7 +39,6 @@ from .lib import (
get_view_process_node,
get_viewer_config_from_string,
deprecated,
- get_head_filename_without_hashes,
get_filenames_without_hash
)
from .pipeline import (
@@ -816,19 +815,20 @@ class ExporterReviewMov(ExporterReview):
self.log.info("File info was set...")
- self.file = self.fhead + self.name + ".{}".format(self.ext)
- if ".{}".format(self.ext) not in VIDEO_EXTENSIONS:
- # filename would be with frame hashes if
- # the file extension is not in video format
- filename = get_head_filename_without_hashes(
- self.path_in, self.name)
- self.file = filename
- # make sure the filename are in
- # correct image output format
- if ".{}".format(self.ext) not in self.file:
- filename_no_ext, _ = os.path.splitext(filename)
- self.file = "{}.{}".format(filename_no_ext, self.ext)
-
+ if ".{}".format(self.ext) in VIDEO_EXTENSIONS:
+ self.file = "{}{}.{}".format(
+ self.fhead, self.name, self.ext)
+ else:
+ # Output is image (or image sequence)
+ # When the file is an image it's possible it
+ # has extra information after the `fhead` that
+ # we want to preserve, e.g. like frame numbers
+ # or frames hashes like `####`
+ filename_no_ext = os.path.splitext(
+ os.path.basename(self.path_in))[0]
+ after_head = filename_no_ext[len(self.fhead):]
+ self.file = "{}{}.{}.{}".format(
+ self.fhead, self.name, after_head, self.ext)
self.path = os.path.join(
self.staging_dir, self.file).replace("\\", "/")
diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py
index 3227a7ed98..635318f53d 100644
--- a/openpype/hosts/nuke/plugins/load/actions.py
+++ b/openpype/hosts/nuke/plugins/load/actions.py
@@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin):
"yeticache",
"pointcache"]
representations = ["*"]
- extension = {"*"}
+ extensions = {"*"}
label = "Set frame range"
order = 11
diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py
index fe82d70b5e..0cbd380697 100644
--- a/openpype/hosts/nuke/plugins/load/load_backdrop.py
+++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py
@@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin):
families = ["workfile", "nukenodes"]
representations = ["*"]
- extension = {"nk"}
+ extensions = {"nk"}
label = "Import Nuke Nodes"
order = 0
diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py
index 2939ceebae..e245b0cb5e 100644
--- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py
+++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py
@@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin):
families = ["camera"]
representations = ["*"]
- extension = {"abc"}
+ extensions = {"abc"}
label = "Load Alembic Camera"
icon = "camera"
diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py
index 89597e76cc..cacc00854e 100644
--- a/openpype/hosts/nuke/plugins/load/load_effects.py
+++ b/openpype/hosts/nuke/plugins/load/load_effects.py
@@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin):
families = ["effect"]
representations = ["*"]
- extension = {"json"}
+ extensions = {"json"}
label = "Load Effects - nodes"
order = 0
diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py
index efe67be4aa..bdf3cd6965 100644
--- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py
+++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py
@@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin):
families = ["effect"]
representations = ["*"]
- extension = {"json"}
+ extensions = {"json"}
label = "Load Effects - Input Process"
order = 0
diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py
index 6b848ee276..ede05c422b 100644
--- a/openpype/hosts/nuke/plugins/load/load_gizmo.py
+++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py
@@ -26,7 +26,7 @@ class LoadGizmo(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
- extension = {"gizmo"}
+ extensions = {"gizmo"}
label = "Load Gizmo"
order = 0
diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py
index a8e1218cbe..d567aaf7b0 100644
--- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py
+++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py
@@ -28,7 +28,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
- extension = {"gizmo"}
+ extensions = {"gizmo"}
label = "Load Gizmo - Input Process"
order = 0
diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py
index f942422c00..14ddf20dc3 100644
--- a/openpype/hosts/nuke/plugins/load/load_matchmove.py
+++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py
@@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin):
families = ["matchmove"]
representations = ["*"]
- extension = {"py"}
+ extensions = {"py"}
defaults = ["Camera", "Object"]
diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py
index 0bdcd93dff..b9b8a0f4c0 100644
--- a/openpype/hosts/nuke/plugins/load/load_model.py
+++ b/openpype/hosts/nuke/plugins/load/load_model.py
@@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin):
families = ["model", "pointcache", "animation"]
representations = ["*"]
- extension = {"abc"}
+ extensions = {"abc"}
label = "Load Alembic"
icon = "cube"
diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py
index 48d4a0900a..d5f9d24765 100644
--- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py
+++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py
@@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin):
families = ["workfile", "nukenodes"]
representations = ["*"]
- extension = {"nk"}
+ extensions = {"nk"}
label = "Load Precomp"
order = 0
diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
index da060e3157..9730e3b61f 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
@@ -33,11 +33,13 @@ class ExtractReviewIntermediates(publish.Extractor):
"""
nuke_publish = project_settings["nuke"]["publish"]
deprecated_setting = nuke_publish["ExtractReviewDataMov"]
- current_setting = nuke_publish["ExtractReviewIntermediates"]
+ current_setting = nuke_publish.get("ExtractReviewIntermediates")
if deprecated_setting["enabled"]:
# Use deprecated settings if they are still enabled
cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"]
cls.outputs = deprecated_setting["outputs"]
+ elif current_setting is None:
+ pass
elif current_setting["enabled"]:
cls.viewer_lut_raw = current_setting["viewer_lut_raw"]
cls.outputs = current_setting["outputs"]
diff --git a/openpype/hosts/resolve/api/__init__.py b/openpype/hosts/resolve/api/__init__.py
index 2b4546f8d6..dba275e6c4 100644
--- a/openpype/hosts/resolve/api/__init__.py
+++ b/openpype/hosts/resolve/api/__init__.py
@@ -6,13 +6,10 @@ from .utils import (
)
from .pipeline import (
- install,
- uninstall,
+ ResolveHost,
ls,
containerise,
update_container,
- publish,
- launch_workfiles_app,
maintained_selection,
remove_instance,
list_instances
@@ -76,14 +73,10 @@ __all__ = [
"bmdvf",
# pipeline
- "install",
- "uninstall",
+ "ResolveHost",
"ls",
"containerise",
"update_container",
- "reload_pipeline",
- "publish",
- "launch_workfiles_app",
"maintained_selection",
"remove_instance",
"list_instances",
diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py
index b3717e01ea..34a63eb89f 100644
--- a/openpype/hosts/resolve/api/menu.py
+++ b/openpype/hosts/resolve/api/menu.py
@@ -5,11 +5,6 @@ from qtpy import QtWidgets, QtCore
from openpype.tools.utils import host_tools
-from .pipeline import (
- publish,
- launch_workfiles_app
-)
-
def load_stylesheet():
path = os.path.join(os.path.dirname(__file__), "menu_style.qss")
@@ -113,7 +108,7 @@ class OpenPypeMenu(QtWidgets.QWidget):
def on_workfile_clicked(self):
print("Clicked Workfile")
- launch_workfiles_app()
+ host_tools.show_workfiles()
def on_create_clicked(self):
print("Clicked Create")
@@ -121,7 +116,7 @@ class OpenPypeMenu(QtWidgets.QWidget):
def on_publish_clicked(self):
print("Clicked Publish")
- publish(None)
+ host_tools.show_publish(parent=None)
def on_load_clicked(self):
print("Clicked Load")
diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py
index 899cb825bb..05f556fa5b 100644
--- a/openpype/hosts/resolve/api/pipeline.py
+++ b/openpype/hosts/resolve/api/pipeline.py
@@ -12,14 +12,24 @@ from openpype.pipeline import (
schema,
register_loader_plugin_path,
register_creator_plugin_path,
- deregister_loader_plugin_path,
- deregister_creator_plugin_path,
AVALON_CONTAINER_ID,
)
-from openpype.tools.utils import host_tools
+from openpype.host import (
+ HostBase,
+ IWorkfileHost,
+ ILoadHost
+)
from . import lib
from .utils import get_resolve_module
+from .workio import (
+ open_file,
+ save_file,
+ file_extensions,
+ has_unsaved_changes,
+ work_root,
+ current_file
+)
log = Logger.get_logger(__name__)
@@ -32,53 +42,56 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
AVALON_CONTAINERS = ":AVALON_CONTAINERS"
-def install():
- """Install resolve-specific functionality of avalon-core.
+class ResolveHost(HostBase, IWorkfileHost, ILoadHost):
+ name = "resolve"
- This is where you install menus and register families, data
- and loaders into resolve.
+ def install(self):
+ """Install resolve-specific functionality of avalon-core.
- It is called automatically when installing via `api.install(resolve)`.
+ This is where you install menus and register families, data
+ and loaders into resolve.
- See the Maya equivalent for inspiration on how to implement this.
+ It is called automatically when installing via `api.install(resolve)`.
- """
+ See the Maya equivalent for inspiration on how to implement this.
- log.info("openpype.hosts.resolve installed")
+ """
- pyblish.register_host("resolve")
- pyblish.register_plugin_path(PUBLISH_PATH)
- log.info("Registering DaVinci Resovle plug-ins..")
+ log.info("openpype.hosts.resolve installed")
- register_loader_plugin_path(LOAD_PATH)
- register_creator_plugin_path(CREATE_PATH)
+ pyblish.register_host(self.name)
+ pyblish.register_plugin_path(PUBLISH_PATH)
+ print("Registering DaVinci Resolve plug-ins..")
- # register callback for switching publishable
- pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled)
+ register_loader_plugin_path(LOAD_PATH)
+ register_creator_plugin_path(CREATE_PATH)
- get_resolve_module()
+ # register callback for switching publishable
+ pyblish.register_callback("instanceToggled",
+ on_pyblish_instance_toggled)
+ get_resolve_module()
-def uninstall():
- """Uninstall all that was installed
+ def open_workfile(self, filepath):
+ return open_file(filepath)
- This is where you undo everything that was done in `install()`.
- That means, removing menus, deregistering families and data
- and everything. It should be as though `install()` was never run,
- because odds are calling this function means the user is interested
- in re-installing shortly afterwards. If, for example, he has been
- modifying the menu or registered families.
+ def save_workfile(self, filepath=None):
+ return save_file(filepath)
- """
- pyblish.deregister_host("resolve")
- pyblish.deregister_plugin_path(PUBLISH_PATH)
- log.info("Deregistering DaVinci Resovle plug-ins..")
+ def work_root(self, session):
+ return work_root(session)
- deregister_loader_plugin_path(LOAD_PATH)
- deregister_creator_plugin_path(CREATE_PATH)
+ def get_current_workfile(self):
+ return current_file()
- # register callback for switching publishable
- pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled)
+ def workfile_has_unsaved_changes(self):
+ return has_unsaved_changes()
+
+ def get_workfile_extensions(self):
+ return file_extensions()
+
+ def get_containers(self):
+ return ls()
def containerise(timeline_item,
@@ -206,15 +219,6 @@ def update_container(timeline_item, data=None):
return bool(lib.set_timeline_item_pype_tag(timeline_item, container))
-def launch_workfiles_app(*args):
- host_tools.show_workfiles()
-
-
-def publish(parent):
- """Shorthand to publish from within host"""
- return host_tools.show_publish()
-
-
@contextlib.contextmanager
def maintained_selection():
"""Maintain selection during context
diff --git a/openpype/hosts/resolve/api/utils.py b/openpype/hosts/resolve/api/utils.py
index 871b3af38d..851851a3b3 100644
--- a/openpype/hosts/resolve/api/utils.py
+++ b/openpype/hosts/resolve/api/utils.py
@@ -17,7 +17,7 @@ def get_resolve_module():
# dont run if already loaded
if api.bmdvr:
log.info(("resolve module is assigned to "
- f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}"))
+ f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}"))
return api.bmdvr
try:
"""
@@ -41,6 +41,10 @@ def get_resolve_module():
)
elif sys.platform.startswith("linux"):
expected_path = "/opt/resolve/libs/Fusion/Modules"
+ else:
+ raise NotImplementedError(
+ "Unsupported platform: {}".format(sys.platform)
+ )
# check if the default path has it...
print(("Unable to find module DaVinciResolveScript from "
@@ -74,6 +78,6 @@ def get_resolve_module():
api.bmdvr = bmdvr
api.bmdvf = bmdvf
log.info(("Assigning resolve module to "
- f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}"))
+ f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}"))
log.info(("Assigning resolve module to "
- f"`pype.hosts.resolve.api.bmdvf`: {api.bmdvf}"))
+ f"`openpype.hosts.resolve.api.bmdvf`: {api.bmdvf}"))
diff --git a/openpype/hosts/resolve/startup.py b/openpype/hosts/resolve/startup.py
index e807a48f5a..5ac3c99524 100644
--- a/openpype/hosts/resolve/startup.py
+++ b/openpype/hosts/resolve/startup.py
@@ -27,7 +27,8 @@ def ensure_installed_host():
if host:
return host
- install_host(openpype.hosts.resolve.api)
+ host = openpype.hosts.resolve.api.ResolveHost()
+ install_host(host)
return registered_host()
@@ -37,10 +38,10 @@ def launch_menu():
openpype.hosts.resolve.api.launch_pype_menu()
-def open_file(path):
+def open_workfile(path):
# Avoid the need to "install" the host
host = ensure_installed_host()
- host.open_file(path)
+ host.open_workfile(path)
def main():
@@ -49,7 +50,7 @@ def main():
if workfile_path and os.path.exists(workfile_path):
log.info(f"Opening last workfile: {workfile_path}")
- open_file(workfile_path)
+ open_workfile(workfile_path)
else:
log.info("No last workfile set to open. Skipping..")
diff --git a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py
index 1087a7b7a0..4f14927074 100644
--- a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py
+++ b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py
@@ -8,12 +8,13 @@ log = Logger.get_logger(__name__)
def main(env):
- import openpype.hosts.resolve.api as bmdvr
+ from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu
# activate resolve from openpype
- install_host(bmdvr)
+ host = ResolveHost()
+ install_host(host)
- bmdvr.launch_pype_menu()
+ launch_pype_menu()
if __name__ == "__main__":
diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py
index 70aa12956d..0b97582d2a 100644
--- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py
@@ -6,6 +6,7 @@ import requests
import pyblish.api
+from openpype import AYON_SERVER_ENABLED
from openpype.pipeline import legacy_io
from openpype.pipeline.publish import (
OpenPypePyblishPluginMixin
@@ -34,6 +35,8 @@ class FusionSubmitDeadline(
targets = ["local"]
# presets
+ plugin = None
+
priority = 50
chunk_size = 1
concurrent_tasks = 1
@@ -173,7 +176,7 @@ class FusionSubmitDeadline(
"SecondaryPool": instance.data.get("secondaryPool"),
"Group": self.group,
- "Plugin": "Fusion",
+ "Plugin": self.plugin,
"Frames": "{start}-{end}".format(
start=int(instance.data["frameStartHandle"]),
end=int(instance.data["frameEndHandle"])
@@ -216,16 +219,29 @@ class FusionSubmitDeadline(
# Include critical variables with submission
keys = [
- # TODO: This won't work if the slaves don't have access to
- # these paths, such as if slaves are running Linux and the
- # submitter is on Windows.
- "PYTHONPATH",
- "OFX_PLUGIN_PATH",
- "FUSION9_MasterPrefs"
+ "FTRACK_API_KEY",
+ "FTRACK_API_USER",
+ "FTRACK_SERVER",
+ "AVALON_PROJECT",
+ "AVALON_ASSET",
+ "AVALON_TASK",
+ "AVALON_APP_NAME",
+ "OPENPYPE_DEV",
+ "OPENPYPE_LOG_NO_COLORS",
+ "IS_TEST"
]
environment = dict({key: os.environ[key] for key in keys
if key in os.environ}, **legacy_io.Session)
+ # to recognize render jobs
+ if AYON_SERVER_ENABLED:
+ environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"]
+ render_job_label = "AYON_RENDER_JOB"
+ else:
+ render_job_label = "OPENPYPE_RENDER_JOB"
+
+ environment[render_job_label] = "1"
+
payload["JobInfo"].update({
"EnvironmentKeyValue%d" % index: "{key}={value}".format(
key=key,
diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py
index a29acf9823..2c55e7c951 100644
--- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py
+++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py
@@ -96,7 +96,7 @@ class AyonDeadlinePlugin(DeadlinePlugin):
for path in exe_list.split(";"):
if path.startswith("~"):
path = os.path.expanduser(path)
- expanded_paths.append(path)
+ expanded_paths.append(path)
exe = FileUtils.SearchFileList(";".join(expanded_paths))
if exe == "":
diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/modules/muster/plugins/publish/submit_maya_muster.py
similarity index 99%
rename from openpype/hosts/maya/plugins/publish/submit_maya_muster.py
rename to openpype/modules/muster/plugins/publish/submit_maya_muster.py
index c174fa7a33..5c95744876 100644
--- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py
+++ b/openpype/modules/muster/plugins/publish/submit_maya_muster.py
@@ -25,6 +25,7 @@ def _get_template_id(renderer):
:rtype: int
"""
+ # TODO: Use settings from context?
templates = get_system_settings()["modules"]["muster"]["templates_mapping"]
if not templates:
raise RuntimeError(("Muster template mapping missing in "
diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
index 047e35e3ac..bdb4b109a1 100644
--- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
+++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py
@@ -1,5 +1,6 @@
import os
import shutil
+import filecmp
from openpype.client.entities import get_representations
from openpype.lib.applications import PreLaunchHook, LaunchTypes
@@ -194,3 +195,69 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
self.data["last_workfile_path"] = local_workfile_path
# Keep source filepath for further path conformation
self.data["source_filepath"] = last_published_workfile_path
+
+ # Get resources directory
+ resources_dir = os.path.join(
+ os.path.dirname(local_workfile_path), 'resources'
+ )
+ # Make resource directory if it doesn't exist
+ if not os.path.exists(resources_dir):
+ os.mkdir(resources_dir)
+
+ # Copy resources to the local resources directory
+ for file in workfile_representation['files']:
+ # Get resource main path
+ resource_main_path = anatomy.fill_root(file["path"])
+
+ # Get resource file basename
+ resource_basename = os.path.basename(resource_main_path)
+
+ # Only copy if the resource file exists, and it's not the workfile
+ if (
+ not os.path.exists(resource_main_path)
+ or resource_basename == os.path.basename(
+ last_published_workfile_path
+ )
+ ):
+ continue
+
+ # Get resource path in workfile folder
+ resource_work_path = os.path.join(
+ resources_dir, resource_basename
+ )
+
+ # Check if the resource file already exists in the resources folder
+ if os.path.exists(resource_work_path):
+ # Check if both files are the same
+ if filecmp.cmp(resource_main_path, resource_work_path):
+ self.log.warning(
+ 'Resource "{}" already exists.'
+ .format(resource_basename)
+ )
+ continue
+ else:
+ # Add `.old` to existing resource path
+ resource_path_old = resource_work_path + '.old'
+ if os.path.exists(resource_work_path + '.old'):
+ for i in range(1, 100):
+ p = resource_path_old + '%02d' % i
+ if not os.path.exists(p):
+ # Rename existing resource file to
+ # `resource_name.old` + 2 digits
+ shutil.move(resource_work_path, p)
+ break
+ else:
+ self.log.warning(
+ 'There are a hundred old files for '
+ 'resource "{}". '
+ 'Perhaps is it time to clean up your '
+ 'resources folder'
+ .format(resource_basename)
+ )
+ continue
+ else:
+ # Rename existing resource file to `resource_name.old`
+ shutil.move(resource_work_path, resource_path_old)
+
+ # Copy resource file to workfile resources folder
+ shutil.copy(resource_main_path, resources_dir)
diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py
index f567118062..13630ae7ca 100644
--- a/openpype/pipeline/context_tools.py
+++ b/openpype/pipeline/context_tools.py
@@ -25,7 +25,10 @@ from openpype.tests.lib import is_in_tests
from .publish.lib import filter_pyblish_plugins
from .anatomy import Anatomy
-from .template_data import get_template_data_with_names
+from .template_data import (
+ get_template_data_with_names,
+ get_template_data
+)
from .workfile import (
get_workfile_template_key,
get_custom_workfile_template_by_string_context,
@@ -658,3 +661,70 @@ def get_process_id():
if _process_id is None:
_process_id = str(uuid.uuid4())
return _process_id
+
+
+def get_current_context_template_data():
+ """Template data for template fill from current context
+
+ Returns:
+ Dict[str, Any] of the following tokens and their values
+ Supported Tokens:
+ - Regular Tokens
+ - app
+ - user
+ - asset
+ - parent
+ - hierarchy
+ - folder[name]
+ - root[work, ...]
+ - studio[code, name]
+ - project[code, name]
+ - task[type, name, short]
+
+ - Context Specific Tokens
+ - assetData[frameStart]
+ - assetData[frameEnd]
+ - assetData[handleStart]
+ - assetData[handleEnd]
+ - assetData[frameStartHandle]
+ - assetData[frameEndHandle]
+ - assetData[resolutionHeight]
+ - assetData[resolutionWidth]
+
+ """
+
+ # pre-prepare get_template_data args
+ current_context = get_current_context()
+ project_name = current_context["project_name"]
+ asset_name = current_context["asset_name"]
+ anatomy = Anatomy(project_name)
+
+ # prepare get_template_data args
+ project_doc = get_project(project_name)
+ asset_doc = get_asset_by_name(project_name, asset_name)
+ task_name = current_context["task_name"]
+ host_name = get_current_host_name()
+
+ # get regular template data
+ template_data = get_template_data(
+ project_doc, asset_doc, task_name, host_name
+ )
+
+ template_data["root"] = anatomy.roots
+
+ # get context specific vars
+ asset_data = asset_doc["data"].copy()
+
+ # compute `frameStartHandle` and `frameEndHandle`
+ if "frameStart" in asset_data and "handleStart" in asset_data:
+ asset_data["frameStartHandle"] = \
+ asset_data["frameStart"] - asset_data["handleStart"]
+
+ if "frameEnd" in asset_data and "handleEnd" in asset_data:
+ asset_data["frameEndHandle"] = \
+ asset_data["frameEnd"] + asset_data["handleEnd"]
+
+ # add assetData
+ template_data["assetData"] = asset_data
+
+ return template_data
diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py
index fe3ab97de8..7ef3439dbd 100644
--- a/openpype/pipeline/farm/pyblish_functions.py
+++ b/openpype/pipeline/farm/pyblish_functions.py
@@ -107,17 +107,18 @@ def get_time_data_from_instance_or_context(instance):
TimeData: dataclass holding time information.
"""
+ context = instance.context
return TimeData(
- start=(instance.data.get("frameStart") or
- instance.context.data.get("frameStart")),
- end=(instance.data.get("frameEnd") or
- instance.context.data.get("frameEnd")),
- fps=(instance.data.get("fps") or
- instance.context.data.get("fps")),
- handle_start=(instance.data.get("handleStart") or
- instance.context.data.get("handleStart")), # noqa: E501
- handle_end=(instance.data.get("handleEnd") or
- instance.context.data.get("handleEnd"))
+ start=instance.data.get("frameStart", context.data.get("frameStart")),
+ end=instance.data.get("frameEnd", context.data.get("frameEnd")),
+ fps=instance.data.get("fps", context.data.get("fps")),
+ step=instance.data.get("byFrameStep", instance.data.get("step", 1)),
+ handle_start=instance.data.get(
+ "handleStart", context.data.get("handleStart")
+ ),
+ handle_end=instance.data.get(
+ "handleEnd", context.data.get("handleEnd")
+ )
)
diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py
index e4fbd91143..1568c41fbd 100644
--- a/openpype/plugins/actions/open_file_explorer.py
+++ b/openpype/plugins/actions/open_file_explorer.py
@@ -83,10 +83,6 @@ class OpenTaskPath(LauncherAction):
if os.path.exists(valid_workdir):
return valid_workdir
- # If task was selected, try to find asset path only to asset
- if not task_name:
- raise AssertionError("Folder does not exist.")
-
data.pop("task", None)
workdir = anatomy.templates_obj["work"]["folder"].format(data)
valid_workdir = self._find_first_filled_path(workdir)
@@ -95,7 +91,7 @@ class OpenTaskPath(LauncherAction):
valid_workdir = os.path.normpath(valid_workdir)
if os.path.exists(valid_workdir):
return valid_workdir
- raise AssertionError("Folder does not exist.")
+ raise AssertionError("Folder does not exist yet.")
@staticmethod
def open_in_explorer(path):
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index 7f1c3b01e2..071ecfffd2 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -213,7 +213,8 @@ class PypeCommands:
pass
def run_tests(self, folder, mark, pyargs,
- test_data_folder, persist, app_variant, timeout, setup_only):
+ test_data_folder, persist, app_variant, timeout, setup_only,
+ mongo_url):
"""
Runs tests from 'folder'
@@ -226,6 +227,10 @@ class PypeCommands:
end
app_variant (str): variant (eg 2020 for AE), empty if use
latest installed version
+ timeout (int): explicit timeout for single test
+ setup_only (bool): if only preparation steps should be
+ triggered, no tests (useful for debugging/development)
+ mongo_url (str): url to Openpype Mongo database
"""
print("run_tests")
if folder:
@@ -264,6 +269,9 @@ class PypeCommands:
if setup_only:
args.extend(["--setup_only", setup_only])
+ if mongo_url:
+ args.extend(["--mongo_url", mongo_url])
+
print("run_tests args: {}".format(args))
import pytest
pytest.main(args)
diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py
index 68693bb953..d54d71e851 100644
--- a/openpype/settings/ayon_settings.py
+++ b/openpype/settings/ayon_settings.py
@@ -748,15 +748,17 @@ def _convert_nuke_project_settings(ayon_settings, output):
)
new_review_data_outputs = {}
- outputs_settings = None
+ outputs_settings = []
# Check deprecated ExtractReviewDataMov
# settings for backwards compatibility
deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"]
current_review_settings = (
- ayon_publish["ExtractReviewIntermediates"]
+ ayon_publish.get("ExtractReviewIntermediates")
)
if deprecrated_review_settings["enabled"]:
outputs_settings = deprecrated_review_settings["outputs"]
+ elif current_review_settings is None:
+ pass
elif current_review_settings["enabled"]:
outputs_settings = current_review_settings["outputs"]
diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json
index 9e88f3b6f2..2c5e0dc65d 100644
--- a/openpype/settings/defaults/project_settings/deadline.json
+++ b/openpype/settings/defaults/project_settings/deadline.json
@@ -52,7 +52,8 @@
"priority": 50,
"chunk_size": 10,
"concurrent_tasks": 1,
- "group": ""
+ "group": "",
+ "plugin": "Fusion"
},
"NukeSubmitDeadline": {
"enabled": true,
diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json
index 5392fc34dd..4f57ee52c6 100644
--- a/openpype/settings/defaults/project_settings/houdini.json
+++ b/openpype/settings/defaults/project_settings/houdini.json
@@ -1,4 +1,16 @@
{
+ "general": {
+ "update_houdini_var_context": {
+ "enabled": true,
+ "houdini_vars":[
+ {
+ "var": "JOB",
+ "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}",
+ "is_directory": true
+ }
+ ]
+ }
+ },
"imageio": {
"activate_host_color_management": true,
"ocio_config": {
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index 83ca6fecef..7719a5e255 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -707,6 +707,9 @@
"CollectMayaRender": {
"sync_workfile_version": false
},
+ "CollectFbxAnimation": {
+ "enabled": true
+ },
"CollectFbxCamera": {
"enabled": false
},
@@ -826,6 +829,11 @@
"redshift_render_attributes": [],
"renderman_render_attributes": []
},
+ "ValidateResolution": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
"ValidateCurrentRenderLayerIsRenderable": {
"enabled": true,
"optional": false,
@@ -1120,6 +1128,11 @@
"optional": true,
"active": true
},
+ "ValidateAnimatedReferenceRig": {
+ "enabled": true,
+ "optional": false,
+ "active": true
+ },
"ValidateAnimationContent": {
"enabled": true,
"optional": false,
@@ -1140,6 +1153,16 @@
"optional": false,
"active": true
},
+ "ValidateSkeletonRigContents": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateSkeletonRigControllers": {
+ "enabled": false,
+ "optional": true,
+ "active": true
+ },
"ValidateSkinclusterDeformerSet": {
"enabled": true,
"optional": false,
@@ -1150,6 +1173,21 @@
"optional": false,
"allow_history_only": false
},
+ "ValidateSkeletonRigOutSetNodeIds": {
+ "enabled": false,
+ "optional": false,
+ "allow_history_only": false
+ },
+ "ValidateSkeletonRigOutputIds": {
+ "enabled": false,
+ "optional": true,
+ "active": true
+ },
+ "ValidateSkeletonTopGroupHierarchy": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
"ValidateCameraAttributes": {
"enabled": false,
"optional": true,
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index f2fc7d933a..2cb75a9515 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -114,6 +114,65 @@
}
}
},
+ "mayapy": {
+ "enabled": true,
+ "label": "MayaPy",
+ "icon": "{}/app_icons/maya.png",
+ "host_name": "maya",
+ "environment": {
+ "MAYA_DISABLE_CLIC_IPM": "Yes",
+ "MAYA_DISABLE_CIP": "Yes",
+ "MAYA_DISABLE_CER": "Yes",
+ "PYMEL_SKIP_MEL_INIT": "Yes",
+ "LC_ALL": "C"
+ },
+ "variants": {
+ "2024": {
+ "use_python_2": false,
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe"
+ ],
+ "darwin": [],
+ "linux": [
+ "/usr/autodesk/maya2024/bin/mayapy"
+ ]
+ },
+ "arguments": {
+ "windows": [
+ "-I"
+ ],
+ "darwin": [],
+ "linux": [
+ "-I"
+ ]
+ },
+ "environment": {}
+ },
+ "2023": {
+ "use_python_2": false,
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe"
+ ],
+ "darwin": [],
+ "linux": [
+ "/usr/autodesk/maya2023/bin/mayapy"
+ ]
+ },
+ "arguments": {
+ "windows": [
+ "-I"
+ ],
+ "darwin": [],
+ "linux": [
+ "-I"
+ ]
+ },
+ "environment": {}
+ }
+ }
+ },
"3dsmax": {
"enabled": true,
"label": "3ds max",
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
index 596bc30f91..64db852c89 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
@@ -289,6 +289,15 @@
"type": "text",
"key": "group",
"label": "Group Name"
+ },
+ {
+ "type": "enum",
+ "key": "plugin",
+ "label": "Deadline Plugin",
+ "enum_items": [
+ {"Fusion": "Fusion"},
+ {"FusionCmd": "FusionCmd"}
+ ]
}
]
},
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json
index 7f782e3647..d4d0565ec9 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json
@@ -5,6 +5,10 @@
"label": "Houdini",
"is_file": true,
"children": [
+ {
+ "type": "schema",
+ "name": "schema_houdini_general"
+ },
{
"key": "imageio",
"type": "dict",
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json
new file mode 100644
index 0000000000..de1a0396ec
--- /dev/null
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json
@@ -0,0 +1,53 @@
+{
+ "type": "dict",
+ "key": "general",
+ "label": "General",
+ "collapsible": true,
+ "is_group": true,
+ "children": [
+ {
+ "type": "dict",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "key": "update_houdini_var_context",
+ "label": "Update Houdini Vars on context change",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "label",
+ "label": "Sync vars with context changes.
If a value is treated as a directory on update it will be ensured the folder exists"
+ },
+ {
+ "type": "list",
+ "key": "houdini_vars",
+ "label": "Houdini Vars",
+ "collapsible": false,
+ "object_type": {
+ "type": "dict",
+ "children": [
+ {
+ "type": "text",
+ "key": "var",
+ "label": "Var"
+ },
+ {
+ "type": "text",
+ "key": "value",
+ "label": "Value"
+ },
+ {
+ "type": "boolean",
+ "key": "is_directory",
+ "label": "Treat as directory"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json
index 13c00ff183..d2e7c51e24 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json
@@ -21,6 +21,20 @@
}
]
},
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "CollectFbxAnimation",
+ "label": "Collect Fbx Animation",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
@@ -417,6 +431,10 @@
"type": "schema_template",
"name": "template_publish_plugin",
"template_data": [
+ {
+ "key": "ValidateResolution",
+ "label": "Validate Resolution Settings"
+ },
{
"key": "ValidateCurrentRenderLayerIsRenderable",
"label": "Validate Current Render Layer Has Renderable Camera"
@@ -793,6 +811,10 @@
"key": "ValidateRigControllers",
"label": "Validate Rig Controllers"
},
+ {
+ "key": "ValidateAnimatedReferenceRig",
+ "label": "Validate Animated Reference Rig"
+ },
{
"key": "ValidateAnimationContent",
"label": "Validate Animation Content"
@@ -809,9 +831,51 @@
"key": "ValidateSkeletalMeshHierarchy",
"label": "Validate Skeletal Mesh Top Node"
},
- {
+ {
+ "key": "ValidateSkeletonRigContents",
+ "label": "Validate Skeleton Rig Contents"
+ },
+ {
+ "key": "ValidateSkeletonRigControllers",
+ "label": "Validate Skeleton Rig Controllers"
+ },
+ {
"key": "ValidateSkinclusterDeformerSet",
"label": "Validate Skincluster Deformer Relationships"
+ },
+ {
+ "key": "ValidateSkeletonRigOutputIds",
+ "label": "Validate Skeleton Rig Output Ids"
+ },
+ {
+ "key": "ValidateSkeletonTopGroupHierarchy",
+ "label": "Validate Skeleton Top Group Hierarchy"
+ }
+ ]
+ },
+
+ {
+ "type": "dict",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "key": "ValidateRigOutSetNodeIds",
+ "label": "Validate Rig Out Set Node Ids",
+ "is_group": true,
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "boolean",
+ "key": "optional",
+ "label": "Optional"
+ },
+ {
+ "type": "boolean",
+ "key": "allow_history_only",
+ "label": "Allow history only"
}
]
},
@@ -819,8 +883,8 @@
"type": "dict",
"collapsible": true,
"checkbox_key": "enabled",
- "key": "ValidateRigOutSetNodeIds",
- "label": "Validate Rig Out Set Node Ids",
+ "key": "ValidateSkeletonRigOutSetNodeIds",
+ "label": "Validate Skeleton Rig Out Set Node Ids",
"is_group": true,
"children": [
{
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json
new file mode 100644
index 0000000000..bbdc7e13b0
--- /dev/null
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json
@@ -0,0 +1,39 @@
+{
+ "type": "dict",
+ "key": "mayapy",
+ "label": "Autodesk MayaPy",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "schema_template",
+ "name": "template_host_unchangables"
+ },
+ {
+ "key": "environment",
+ "label": "Environment",
+ "type": "raw-json"
+ },
+ {
+ "type": "dict-modifiable",
+ "key": "variants",
+ "collapsible_key": true,
+ "use_label_wrap": false,
+ "object_type": {
+ "type": "dict",
+ "collapsible": true,
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_host_variant_items"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json
index abea37a9ab..7965c344ae 100644
--- a/openpype/settings/entities/schemas/system_schema/schema_applications.json
+++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json
@@ -9,6 +9,10 @@
"type": "schema",
"name": "schema_maya"
},
+ {
+ "type": "schema",
+ "name": "schema_mayapy"
+ },
{
"type": "schema",
"name": "schema_3dsmax"
diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py
index 00502fe930..95fe2b2c8d 100644
--- a/openpype/tools/ayon_launcher/abstract.py
+++ b/openpype/tools/ayon_launcher/abstract.py
@@ -272,7 +272,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
@abstractmethod
def set_application_force_not_open_workfile(
- self, project_name, folder_id, task_id, action_id, enabled
+ self, project_name, folder_id, task_id, action_ids, enabled
):
"""This is application action related to force not open last workfile.
@@ -280,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
- action_id (str): Action identifier.
+ action_id (Iterable[str]): Action identifiers.
enabled (bool): New value of force not open workfile.
"""
@@ -295,3 +295,13 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""
pass
+
+ @abstractmethod
+ def refresh_actions(self):
+ """Refresh actions and all related data.
+
+ Triggers 'controller.refresh.actions.started' event at the beginning
+ and 'controller.refresh.actions.finished' at the end.
+ """
+
+ pass
diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py
index 09e07893c3..36c0536422 100644
--- a/openpype/tools/ayon_launcher/control.py
+++ b/openpype/tools/ayon_launcher/control.py
@@ -121,10 +121,10 @@ class BaseLauncherController(
project_name, folder_id, task_id)
def set_application_force_not_open_workfile(
- self, project_name, folder_id, task_id, action_id, enabled
+ self, project_name, folder_id, task_id, action_ids, enabled
):
self._actions_model.set_application_force_not_open_workfile(
- project_name, folder_id, task_id, action_id, enabled
+ project_name, folder_id, task_id, action_ids, enabled
)
def trigger_action(self, project_name, folder_id, task_id, identifier):
@@ -145,5 +145,17 @@ class BaseLauncherController(
self._emit_event("controller.refresh.finished")
+ def refresh_actions(self):
+ self._emit_event("controller.refresh.actions.started")
+
+ # Refresh project settings (used for actions discovery)
+ self._project_settings = {}
+ # Refresh projects - they define applications
+ self._projects_model.reset()
+ # Refresh actions
+ self._actions_model.refresh()
+
+ self._emit_event("controller.refresh.actions.finished")
+
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")
diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py
index 24fea44db2..93ec115734 100644
--- a/openpype/tools/ayon_launcher/models/actions.py
+++ b/openpype/tools/ayon_launcher/models/actions.py
@@ -326,13 +326,14 @@ class ActionsModel:
return output
def set_application_force_not_open_workfile(
- self, project_name, folder_id, task_id, action_id, enabled
+ self, project_name, folder_id, task_id, action_ids, enabled
):
no_workfile_reg_data = self._get_no_last_workfile_reg_data()
project_data = no_workfile_reg_data.setdefault(project_name, {})
folder_data = project_data.setdefault(folder_id, {})
task_data = folder_data.setdefault(task_id, {})
- task_data[action_id] = enabled
+ for action_id in action_ids:
+ task_data[action_id] = enabled
self._launcher_tool_reg.set_item(
self._not_open_workfile_reg_key, no_workfile_reg_data
)
@@ -359,7 +360,10 @@ class ActionsModel:
project_name, folder_id, task_id
)
force_not_open_workfile = per_action.get(identifier, False)
- action.data["start_last_workfile"] = force_not_open_workfile
+ if force_not_open_workfile:
+ action.data["start_last_workfile"] = False
+ else:
+ action.data.pop("start_last_workfile", None)
action.process(session)
except Exception as exc:
self.log.warning("Action trigger failed.", exc_info=True)
diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py
index d04f8f8d24..2a1a06695d 100644
--- a/openpype/tools/ayon_launcher/ui/actions_widget.py
+++ b/openpype/tools/ayon_launcher/ui/actions_widget.py
@@ -19,6 +19,21 @@ ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6
FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7
+def _variant_label_sort_getter(action_item):
+ """Get variant label value for sorting.
+
+ Make sure the output value is a string.
+
+ Args:
+ action_item (ActionItem): Action item.
+
+ Returns:
+ str: Variant label or empty string.
+ """
+
+ return action_item.variant_label or ""
+
+
class ActionsQtModel(QtGui.QStandardItemModel):
"""Qt model for actions.
@@ -31,10 +46,6 @@ class ActionsQtModel(QtGui.QStandardItemModel):
def __init__(self, controller):
super(ActionsQtModel, self).__init__()
- controller.register_event_callback(
- "controller.refresh.finished",
- self._on_controller_refresh_finished,
- )
controller.register_event_callback(
"selection.project.changed",
self._on_selection_project_changed,
@@ -51,6 +62,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
self._controller = controller
self._items_by_id = {}
+ self._action_items_by_id = {}
self._groups_by_id = {}
self._selected_project_name = None
@@ -72,8 +84,12 @@ class ActionsQtModel(QtGui.QStandardItemModel):
def get_item_by_id(self, action_id):
return self._items_by_id.get(action_id)
+ def get_action_item_by_id(self, action_id):
+ return self._action_items_by_id.get(action_id)
+
def _clear_items(self):
self._items_by_id = {}
+ self._action_items_by_id = {}
self._groups_by_id = {}
root = self.invisibleRootItem()
root.removeRows(0, root.rowCount())
@@ -101,12 +117,14 @@ class ActionsQtModel(QtGui.QStandardItemModel):
groups_by_id = {}
for action_items in items_by_label.values():
+ action_items.sort(key=_variant_label_sort_getter, reverse=True)
first_item = next(iter(action_items))
all_action_items_info.append((first_item, len(action_items) > 1))
groups_by_id[first_item.identifier] = action_items
new_items = []
items_by_id = {}
+ action_items_by_id = {}
for action_item_info in all_action_items_info:
action_item, is_group = action_item_info
icon = get_qt_icon(action_item.icon)
@@ -132,6 +150,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
action_item.force_not_open_workfile,
FORCE_NOT_OPEN_WORKFILE_ROLE)
items_by_id[action_item.identifier] = item
+ action_items_by_id[action_item.identifier] = action_item
if new_items:
root_item.appendRows(new_items)
@@ -139,19 +158,14 @@ class ActionsQtModel(QtGui.QStandardItemModel):
to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys())
for identifier in to_remove:
item = self._items_by_id.pop(identifier)
+ self._action_items_by_id.pop(identifier)
root_item.removeRow(item.row())
self._groups_by_id = groups_by_id
self._items_by_id = items_by_id
+ self._action_items_by_id = action_items_by_id
self.refreshed.emit()
- def _on_controller_refresh_finished(self):
- context = self._controller.get_selected_context()
- self._selected_project_name = context["project_name"]
- self._selected_folder_id = context["folder_id"]
- self._selected_task_id = context["task_id"]
- self.refresh()
-
def _on_selection_project_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = None
@@ -336,6 +350,9 @@ class ActionsWidget(QtWidgets.QWidget):
self._set_row_height(1)
+ def refresh(self):
+ self._model.refresh()
+
def _set_row_height(self, rows):
self.setMinimumHeight(rows * 75)
@@ -387,9 +404,15 @@ class ActionsWidget(QtWidgets.QWidget):
checkbox.setChecked(True)
action_id = index.data(ACTION_ID_ROLE)
+ is_group = index.data(ACTION_IS_GROUP_ROLE)
+ if is_group:
+ action_items = self._model.get_group_items(action_id)
+ else:
+ action_items = [self._model.get_action_item_by_id(action_id)]
+ action_ids = {action_item.identifier for action_item in action_items}
checkbox.stateChanged.connect(
lambda: self._on_checkbox_changed(
- action_id, checkbox.isChecked()
+ action_ids, checkbox.isChecked()
)
)
action = QtWidgets.QWidgetAction(menu)
@@ -402,7 +425,7 @@ class ActionsWidget(QtWidgets.QWidget):
menu.exec_(global_point)
self._context_menu = None
- def _on_checkbox_changed(self, action_id, is_checked):
+ def _on_checkbox_changed(self, action_ids, is_checked):
if self._context_menu is not None:
self._context_menu.close()
@@ -410,7 +433,7 @@ class ActionsWidget(QtWidgets.QWidget):
folder_id = self._model.get_selected_folder_id()
task_id = self._model.get_selected_task_id()
self._controller.set_application_force_not_open_workfile(
- project_name, folder_id, task_id, action_id, is_checked)
+ project_name, folder_id, task_id, action_ids, is_checked)
self._model.refresh()
def _on_clicked(self, index):
diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py
index 5047cdc692..8c546b38ac 100644
--- a/openpype/tools/ayon_launcher/ui/hierarchy_page.py
+++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py
@@ -92,6 +92,10 @@ class HierarchyPage(QtWidgets.QWidget):
if visible and project_name:
self._projects_combobox.set_selection(project_name)
+ def refresh(self):
+ self._folders_widget.refresh()
+ self._tasks_widget.refresh()
+
def _on_back_clicked(self):
self._controller.set_selected_project(None)
diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py
index baa399d0ed..7dbaec5147 100644
--- a/openpype/tools/ayon_launcher/ui/projects_widget.py
+++ b/openpype/tools/ayon_launcher/ui/projects_widget.py
@@ -73,6 +73,9 @@ class ProjectIconView(QtWidgets.QListView):
class ProjectsWidget(QtWidgets.QWidget):
"""Projects Page"""
+
+ refreshed = QtCore.Signal()
+
def __init__(self, controller, parent=None):
super(ProjectsWidget, self).__init__(parent=parent)
@@ -104,6 +107,7 @@ class ProjectsWidget(QtWidgets.QWidget):
main_layout.addWidget(projects_view, 1)
projects_view.clicked.connect(self._on_view_clicked)
+ projects_model.refreshed.connect(self.refreshed)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
refresh_btn.clicked.connect(self._on_refresh_clicked)
@@ -119,6 +123,15 @@ class ProjectsWidget(QtWidgets.QWidget):
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
+ def has_content(self):
+ """Model has at least one project.
+
+ Returns:
+ bool: True if there is any content in the model.
+ """
+
+ return self._projects_model.has_content()
+
def _on_view_clicked(self, index):
if index.isValid():
project_name = index.data(QtCore.Qt.DisplayRole)
diff --git a/openpype/tools/ayon_launcher/ui/window.py b/openpype/tools/ayon_launcher/ui/window.py
index 139da42a2e..ffc74a2fdc 100644
--- a/openpype/tools/ayon_launcher/ui/window.py
+++ b/openpype/tools/ayon_launcher/ui/window.py
@@ -99,8 +99,8 @@ class LauncherWindow(QtWidgets.QWidget):
message_timer.setInterval(self.message_interval)
message_timer.setSingleShot(True)
- refresh_timer = QtCore.QTimer()
- refresh_timer.setInterval(self.refresh_interval)
+ actions_refresh_timer = QtCore.QTimer()
+ actions_refresh_timer.setInterval(self.refresh_interval)
page_slide_anim = QtCore.QVariantAnimation(self)
page_slide_anim.setDuration(self.page_side_anim_interval)
@@ -108,8 +108,10 @@ class LauncherWindow(QtWidgets.QWidget):
page_slide_anim.setEndValue(1.0)
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
+ projects_page.refreshed.connect(self._on_projects_refresh)
message_timer.timeout.connect(self._on_message_timeout)
- refresh_timer.timeout.connect(self._on_refresh_timeout)
+ actions_refresh_timer.timeout.connect(
+ self._on_actions_refresh_timeout)
page_slide_anim.valueChanged.connect(
self._on_page_slide_value_changed)
page_slide_anim.finished.connect(self._on_page_slide_finished)
@@ -132,6 +134,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._is_on_projects_page = True
self._window_is_active = False
self._refresh_on_activate = False
+ self._selected_project_name = None
self._pages_widget = pages_widget
self._pages_layout = pages_layout
@@ -143,7 +146,7 @@ class LauncherWindow(QtWidgets.QWidget):
# self._action_history = action_history
self._message_timer = message_timer
- self._refresh_timer = refresh_timer
+ self._actions_refresh_timer = actions_refresh_timer
self._page_slide_anim = page_slide_anim
hierarchy_page.setVisible(not self._is_on_projects_page)
@@ -152,14 +155,14 @@ class LauncherWindow(QtWidgets.QWidget):
def showEvent(self, event):
super(LauncherWindow, self).showEvent(event)
self._window_is_active = True
- if not self._refresh_timer.isActive():
- self._refresh_timer.start()
+ if not self._actions_refresh_timer.isActive():
+ self._actions_refresh_timer.start()
self._controller.refresh()
def closeEvent(self, event):
super(LauncherWindow, self).closeEvent(event)
self._window_is_active = False
- self._refresh_timer.stop()
+ self._actions_refresh_timer.stop()
def changeEvent(self, event):
if event.type() in (
@@ -170,15 +173,15 @@ class LauncherWindow(QtWidgets.QWidget):
self._window_is_active = is_active
if is_active and self._refresh_on_activate:
self._refresh_on_activate = False
- self._on_refresh_timeout()
- self._refresh_timer.start()
+ self._on_actions_refresh_timeout()
+ self._actions_refresh_timer.start()
super(LauncherWindow, self).changeEvent(event)
- def _on_refresh_timeout(self):
+ def _on_actions_refresh_timeout(self):
# Stop timer if widget is not visible
if self._window_is_active:
- self._controller.refresh()
+ self._controller.refresh_actions()
else:
self._refresh_on_activate = True
@@ -191,12 +194,26 @@ class LauncherWindow(QtWidgets.QWidget):
def _on_project_selection_change(self, event):
project_name = event["project_name"]
+ self._selected_project_name = project_name
if not project_name:
self._go_to_projects_page()
elif self._is_on_projects_page:
self._go_to_hierarchy_page(project_name)
+ def _on_projects_refresh(self):
+ # There is nothing to do, we're on projects page
+ if self._is_on_projects_page:
+ return
+
+ # No projects were found -> go back to projects page
+ if not self._projects_page.has_content():
+ self._go_to_projects_page()
+ return
+
+ self._hierarchy_page.refresh()
+ self._actions_widget.refresh()
+
def _on_action_trigger_started(self, event):
self._echo("Running action: {}".format(event["full_label"]))
diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py
index 8e01c557c5..93f4c48d98 100644
--- a/openpype/tools/ayon_utils/models/hierarchy.py
+++ b/openpype/tools/ayon_utils/models/hierarchy.py
@@ -199,13 +199,18 @@ class HierarchyModel(object):
Hierarchy items are folders and tasks. Folders can have as parent another
folder or project. Tasks can have as parent only folder.
"""
+ lifetime = 60 # A minute
def __init__(self, controller):
- self._folders_items = NestedCacheItem(levels=1, default_factory=dict)
- self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict)
+ self._folders_items = NestedCacheItem(
+ levels=1, default_factory=dict, lifetime=self.lifetime)
+ self._folders_by_id = NestedCacheItem(
+ levels=2, default_factory=dict, lifetime=self.lifetime)
- self._task_items = NestedCacheItem(levels=2, default_factory=dict)
- self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict)
+ self._task_items = NestedCacheItem(
+ levels=2, default_factory=dict, lifetime=self.lifetime)
+ self._tasks_by_id = NestedCacheItem(
+ levels=2, default_factory=dict, lifetime=self.lifetime)
self._folders_refreshing = set()
self._tasks_refreshing = set()
diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py
index 3fab64f657..4f44881081 100644
--- a/openpype/tools/ayon_utils/widgets/folders_widget.py
+++ b/openpype/tools/ayon_utils/widgets/folders_widget.py
@@ -56,11 +56,21 @@ class FoldersModel(QtGui.QStandardItemModel):
return self._has_content
- def clear(self):
+ def refresh(self):
+ """Refresh folders for last selected project.
+
+ Force to update folders model from controller. This may or may not
+ trigger query from server, that's based on controller's cache.
+ """
+
+ self.set_project_name(self._last_project_name)
+
+ def _clear_items(self):
self._items_by_id = {}
self._parent_id_by_id = {}
self._has_content = False
- super(FoldersModel, self).clear()
+ root_item = self.invisibleRootItem()
+ root_item.removeRows(0, root_item.rowCount())
def get_index_by_id(self, item_id):
"""Get index by folder id.
@@ -90,7 +100,7 @@ class FoldersModel(QtGui.QStandardItemModel):
self._is_refreshing = True
if self._last_project_name != project_name:
- self.clear()
+ self._clear_items()
self._last_project_name = project_name
thread = self._refresh_threads.get(project_name)
@@ -135,7 +145,7 @@ class FoldersModel(QtGui.QStandardItemModel):
def _fill_items(self, folder_items_by_id):
if not folder_items_by_id:
if folder_items_by_id is not None:
- self.clear()
+ self._clear_items()
self._is_refreshing = False
self.refreshed.emit()
return
@@ -247,6 +257,7 @@ class FoldersWidget(QtWidgets.QWidget):
folders_model = FoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model.setSourceModel(folders_model)
+ folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
folders_view.setModel(folders_proxy_model)
@@ -293,6 +304,14 @@ class FoldersWidget(QtWidgets.QWidget):
self._folders_proxy_model.setFilterFixedString(name)
+ def refresh(self):
+ """Refresh folders model.
+
+ Force to update folders model from controller.
+ """
+
+ self._folders_model.refresh()
+
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._set_project_name(project_name)
@@ -300,9 +319,6 @@ class FoldersWidget(QtWidgets.QWidget):
def _set_project_name(self, project_name):
self._folders_model.set_project_name(project_name)
- def _clear(self):
- self._folders_model.clear()
-
def _on_folders_refresh_finished(self, event):
if event["sender"] != SENDER_NAME:
self._set_project_name(event["project_name"])
diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py
index 66ebd0b777..0af506863a 100644
--- a/openpype/tools/ayon_utils/widgets/tasks_widget.py
+++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py
@@ -44,14 +44,20 @@ class TasksModel(QtGui.QStandardItemModel):
# Initial state
self._add_invalid_selection_item()
- def clear(self):
+ def _clear_items(self):
self._items_by_name = {}
self._has_content = False
self._remove_invalid_items()
- super(TasksModel, self).clear()
+ root_item = self.invisibleRootItem()
+ root_item.removeRows(0, root_item.rowCount())
- def refresh(self, project_name, folder_id):
- """Refresh tasks for folder.
+ def refresh(self):
+ """Refresh tasks for last project and folder."""
+
+ self._refresh(self._last_project_name, self._last_folder_id)
+
+ def set_context(self, project_name, folder_id):
+ """Set context for which should be tasks showed.
Args:
project_name (Union[str]): Name of project.
@@ -121,7 +127,7 @@ class TasksModel(QtGui.QStandardItemModel):
return self._empty_tasks_item
def _add_invalid_item(self, item):
- self.clear()
+ self._clear_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
@@ -299,6 +305,7 @@ class TasksWidget(QtWidgets.QWidget):
tasks_model = TasksModel(controller)
tasks_proxy_model = QtCore.QSortFilterProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
+ tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
tasks_view.setModel(tasks_proxy_model)
@@ -334,8 +341,14 @@ class TasksWidget(QtWidgets.QWidget):
self._handle_expected_selection = handle_expected_selection
self._expected_selection_data = None
- def _clear(self):
- self._tasks_model.clear()
+ def refresh(self):
+ """Refresh folders for last selected project.
+
+ Force to update folders model from controller. This may or may not
+ trigger query from server, that's based on controller's cache.
+ """
+
+ self._tasks_model.refresh()
def _on_tasks_refresh_finished(self, event):
"""Tasks were refreshed in controller.
@@ -353,13 +366,13 @@ class TasksWidget(QtWidgets.QWidget):
or event["folder_id"] != self._selected_folder_id
):
return
- self._tasks_model.refresh(
+ self._tasks_model.set_context(
event["project_name"], self._selected_folder_id
)
def _folder_selection_changed(self, event):
self._selected_folder_id = event["folder_id"]
- self._tasks_model.refresh(
+ self._tasks_model.set_context(
event["project_name"], self._selected_folder_id
)
diff --git a/openpype/version.py b/openpype/version.py
index 8234258f19..1a316df989 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.17.2-nightly.1"
+__version__ = "3.17.2-nightly.4"
diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json
index 8e5b28623e..60305cf1c4 100644
--- a/server_addon/applications/server/applications.json
+++ b/server_addon/applications/server/applications.json
@@ -109,6 +109,55 @@
}
]
},
+ "maya": {
+ "enabled": true,
+ "label": "Maya",
+ "icon": "{}/app_icons/maya.png",
+ "host_name": "maya",
+ "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n",
+ "variants": [
+ {
+ "name": "2024",
+ "label": "2024",
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe"
+ ],
+ "darwin": [],
+ "linux": [
+ "/usr/autodesk/maya2024/bin/mayapy"
+ ]
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}",
+ "use_python_2": false
+ },
+ {
+ "name": "2023",
+ "label": "2023",
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe"
+ ],
+ "darwin": [],
+ "linux": [
+ "/usr/autodesk/maya2023/bin/mayapy"
+ ]
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": "{\n \"MAYA_VERSION\": \"2023\"\n}",
+ "use_python_2": false
+ }
+ ]
+ },
"adsk_3dsmax": {
"enabled": true,
"label": "3ds Max",
@@ -237,6 +286,7 @@
},
{
"name": "13-0",
+ "label": "13.0",
"use_python_2": false,
"executables": {
"windows": [
@@ -319,6 +369,7 @@
},
{
"name": "13-0",
+ "label": "13.0",
"use_python_2": false,
"executables": {
"windows": [
@@ -405,6 +456,7 @@
},
{
"name": "13-0",
+ "label": "13.0",
"use_python_2": false,
"executables": {
"windows": [
@@ -491,6 +543,7 @@
},
{
"name": "13-0",
+ "label": "13.0",
"use_python_2": false,
"executables": {
"windows": [
@@ -577,6 +630,7 @@
},
{
"name": "13-0",
+ "label": "13.0",
"use_python_2": false,
"executables": {
"windows": [
diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py
index 32a5d0e353..8d48695a9c 100644
--- a/server_addon/deadline/server/settings/publish_plugins.py
+++ b/server_addon/deadline/server/settings/publish_plugins.py
@@ -124,6 +124,24 @@ class LimitGroupsSubmodel(BaseSettingsModel):
)
+def fusion_deadline_plugin_enum():
+ """Return a list of value/label dicts for the enumerator.
+
+ Returning a list of dicts is used to allow for a custom label to be
+ displayed in the UI.
+ """
+ return [
+ {
+ "value": "Fusion",
+ "label": "Fusion"
+ },
+ {
+ "value": "FusionCmd",
+ "label": "FusionCmd"
+ }
+ ]
+
+
class FusionSubmitDeadlineModel(BaseSettingsModel):
enabled: bool = Field(True, title="Enabled")
optional: bool = Field(False, title="Optional")
@@ -132,6 +150,9 @@ class FusionSubmitDeadlineModel(BaseSettingsModel):
chunk_size: int = Field(10, title="Frame per Task")
concurrent_tasks: int = Field(1, title="Number of concurrent tasks")
group: str = Field("", title="Group Name")
+ plugin: str = Field("Fusion",
+ enum_resolver=fusion_deadline_plugin_enum,
+ title="Deadline Plugin")
class NukeSubmitDeadlineModel(BaseSettingsModel):
diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py
index 485f44ac21..b3f4756216 100644
--- a/server_addon/deadline/server/version.py
+++ b/server_addon/deadline/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.1"
+__version__ = "0.1.2"
diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py
new file mode 100644
index 0000000000..21cc4c452c
--- /dev/null
+++ b/server_addon/houdini/server/settings/general.py
@@ -0,0 +1,45 @@
+from pydantic import Field
+from ayon_server.settings import BaseSettingsModel
+
+
+class HoudiniVarModel(BaseSettingsModel):
+ _layout = "expanded"
+ var: str = Field("", title="Var")
+ value: str = Field("", title="Value")
+ is_directory: bool = Field(False, title="Treat as directory")
+
+
+class UpdateHoudiniVarcontextModel(BaseSettingsModel):
+ """Sync vars with context changes.
+
+ If a value is treated as a directory on update
+ it will be ensured the folder exists.
+ """
+
+ enabled: bool = Field(title="Enabled")
+ # TODO this was dynamic dictionary '{var: path}'
+ houdini_vars: list[HoudiniVarModel] = Field(
+ default_factory=list,
+ title="Houdini Vars"
+ )
+
+
+class GeneralSettingsModel(BaseSettingsModel):
+ update_houdini_var_context: UpdateHoudiniVarcontextModel = Field(
+ default_factory=UpdateHoudiniVarcontextModel,
+ title="Update Houdini Vars on context change"
+ )
+
+
+DEFAULT_GENERAL_SETTINGS = {
+ "update_houdini_var_context": {
+ "enabled": True,
+ "houdini_vars": [
+ {
+ "var": "JOB",
+ "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa
+ "is_directory": True
+ }
+ ]
+ }
+}
diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py
index fdb6838f5c..0c2e160c87 100644
--- a/server_addon/houdini/server/settings/main.py
+++ b/server_addon/houdini/server/settings/main.py
@@ -4,7 +4,10 @@ from ayon_server.settings import (
MultiplatformPathModel,
MultiplatformPathListModel,
)
-
+from .general import (
+ GeneralSettingsModel,
+ DEFAULT_GENERAL_SETTINGS
+)
from .imageio import HoudiniImageIOModel
from .publish_plugins import (
PublishPluginsModel,
@@ -52,6 +55,10 @@ class ShelvesModel(BaseSettingsModel):
class HoudiniSettings(BaseSettingsModel):
+ general: GeneralSettingsModel = Field(
+ default_factory=GeneralSettingsModel,
+ title="General"
+ )
imageio: HoudiniImageIOModel = Field(
default_factory=HoudiniImageIOModel,
title="Color Management (ImageIO)"
@@ -73,6 +80,7 @@ class HoudiniSettings(BaseSettingsModel):
DEFAULT_VALUES = {
+ "general": DEFAULT_GENERAL_SETTINGS,
"shelves": [],
"create": DEFAULT_HOUDINI_CREATE_SETTINGS,
"publish": DEFAULT_HOUDINI_PUBLISH_SETTINGS
diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py
index ae7362549b..bbab0242f6 100644
--- a/server_addon/houdini/server/version.py
+++ b/server_addon/houdini/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.3"
+__version__ = "0.1.4"
diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py
index bd7ccdf4d5..dd8d4a0a37 100644
--- a/server_addon/maya/server/settings/publishers.py
+++ b/server_addon/maya/server/settings/publishers.py
@@ -129,6 +129,10 @@ class CollectMayaRenderModel(BaseSettingsModel):
)
+class CollectFbxAnimationModel(BaseSettingsModel):
+ enabled: bool = Field(title="Collect Fbx Animation")
+
+
class CollectFbxCameraModel(BaseSettingsModel):
enabled: bool = Field(title="CollectFbxCamera")
@@ -364,6 +368,10 @@ class PublishersModel(BaseSettingsModel):
title="Collect Render Layers",
section="Collectors"
)
+ CollectFbxAnimation: CollectFbxAnimationModel = Field(
+ default_factory=CollectFbxAnimationModel,
+ title="Collect FBX Animation",
+ )
CollectFbxCamera: CollectFbxCameraModel = Field(
default_factory=CollectFbxCameraModel,
title="Collect Camera for FBX export",
@@ -425,6 +433,10 @@ class PublishersModel(BaseSettingsModel):
default_factory=ValidateRenderSettingsModel,
title="Validate Render Settings"
)
+ ValidateResolution: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Resolution Setting"
+ )
ValidateCurrentRenderLayerIsRenderable: BasicValidateModel = Field(
default_factory=BasicValidateModel,
title="Validate Current Render Layer Has Renderable Camera"
@@ -644,6 +656,10 @@ class PublishersModel(BaseSettingsModel):
default_factory=BasicValidateModel,
title="Validate Rig Controllers",
)
+ ValidateAnimatedReferenceRig: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Animated Reference Rig",
+ )
ValidateAnimationContent: BasicValidateModel = Field(
default_factory=BasicValidateModel,
title="Validate Animation Content",
@@ -660,14 +676,34 @@ class PublishersModel(BaseSettingsModel):
default_factory=BasicValidateModel,
title="Validate Skeletal Mesh Top Node",
)
+ ValidateSkeletonRigContents: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Skeleton Rig Contents"
+ )
+ ValidateSkeletonRigControllers: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Skeleton Rig Controllers"
+ )
ValidateSkinclusterDeformerSet: BasicValidateModel = Field(
default_factory=BasicValidateModel,
title="Validate Skincluster Deformer Relationships",
)
+ ValidateSkeletonRigOutputIds: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Skeleton Rig Output Ids"
+ )
+ ValidateSkeletonTopGroupHierarchy: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Skeleton Top Group Hierarchy",
+ )
ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field(
default_factory=ValidateRigOutSetNodeIdsModel,
title="Validate Rig Out Set Node Ids",
)
+ ValidateSkeletonRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field(
+ default_factory=ValidateRigOutSetNodeIdsModel,
+ title="Validate Skeleton Rig Out Set Node Ids",
+ )
# Rig - END
ValidateCameraAttributes: BasicValidateModel = Field(
default_factory=BasicValidateModel,
@@ -748,6 +784,9 @@ DEFAULT_PUBLISH_SETTINGS = {
"CollectMayaRender": {
"sync_workfile_version": False
},
+ "CollectFbxAnimation": {
+ "enabled": True
+ },
"CollectFbxCamera": {
"enabled": False
},
@@ -867,6 +906,11 @@ DEFAULT_PUBLISH_SETTINGS = {
"redshift_render_attributes": [],
"renderman_render_attributes": []
},
+ "ValidateResolution": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
"ValidateCurrentRenderLayerIsRenderable": {
"enabled": True,
"optional": False,
@@ -1143,6 +1187,11 @@ DEFAULT_PUBLISH_SETTINGS = {
"optional": True,
"active": True
},
+ "ValidateAnimatedReferenceRig": {
+ "enabled": True,
+ "optional": False,
+ "active": True
+ },
"ValidateAnimationContent": {
"enabled": True,
"optional": False,
@@ -1163,6 +1212,16 @@ DEFAULT_PUBLISH_SETTINGS = {
"optional": False,
"active": True
},
+ "ValidateSkeletonRigContents": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateSkeletonRigControllers": {
+ "enabled": False,
+ "optional": True,
+ "active": True
+ },
"ValidateSkinclusterDeformerSet": {
"enabled": True,
"optional": False,
@@ -1173,6 +1232,21 @@ DEFAULT_PUBLISH_SETTINGS = {
"optional": False,
"allow_history_only": False
},
+ "ValidateSkeletonRigOutSetNodeIds": {
+ "enabled": False,
+ "optional": False,
+ "allow_history_only": False
+ },
+ "ValidateSkeletonRigOutputIds": {
+ "enabled": False,
+ "optional": True,
+ "active": True
+ },
+ "ValidateSkeletonTopGroupHierarchy": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
"ValidateCameraAttributes": {
"enabled": False,
"optional": True,
diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py
index e57ad00718..90ce344d3e 100644
--- a/server_addon/maya/server/version.py
+++ b/server_addon/maya/server/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring addon version."""
-__version__ = "0.1.3"
+__version__ = "0.1.5"
diff --git a/tests/conftest.py b/tests/conftest.py
index 4f7c17244b..6e82c9917d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,6 +29,11 @@ def pytest_addoption(parser):
help="True - only setup test, do not run any tests"
)
+ parser.addoption(
+ "--mongo_url", action="store", default=None,
+ help="Provide url of the Mongo database."
+ )
+
@pytest.fixture(scope="module")
def test_data_folder(request):
@@ -55,6 +60,11 @@ def setup_only(request):
return request.config.getoption("--setup_only")
+@pytest.fixture(scope="module")
+def mongo_url(request):
+ return request.config.getoption("--mongo_url")
+
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# execute all other hooks to obtain the report object
diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py
index 2af4af02de..e82e438e54 100644
--- a/tests/lib/testing_classes.py
+++ b/tests/lib/testing_classes.py
@@ -147,11 +147,11 @@ class ModuleUnitTest(BaseTest):
@pytest.fixture(scope="module")
def db_setup(self, download_test_data, env_var, monkeypatch_session,
- request):
+ request, mongo_url):
"""Restore prepared MongoDB dumps into selected DB."""
backup_dir = os.path.join(download_test_data, "input", "dumps")
- uri = os.environ.get("OPENPYPE_MONGO")
+ uri = mongo_url or os.environ.get("OPENPYPE_MONGO")
db_handler = DBHandler(uri)
db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir,
overwrite=True,
diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md
index 64c54db591..18c390e07f 100644
--- a/website/docs/admin_hosts_houdini.md
+++ b/website/docs/admin_hosts_houdini.md
@@ -3,9 +3,36 @@ id: admin_hosts_houdini
title: Houdini
sidebar_label: Houdini
---
+## General Settings
+### Houdini Vars
+
+Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.
+
+Using template keys is supported but formatting keys capitalization variants is not, e.g. `{Asset}` and `{ASSET}` won't work
+
+
+:::note
+If `Treat as directory` toggle is activated, Openpype will consider the given value is a path of a folder.
+
+If the folder does not exist on the context change it will be created by this feature so that the path will always try to point to an existing folder.
+:::
+
+Disabling `Update Houdini vars on context change` feature will leave all Houdini vars unmanaged and thus no context update changes will occur.
+
+> If `$JOB` is present in the Houdini var list and has an empty value, OpenPype will set its value to `$HIP`
+
+
+:::note
+For consistency reasons we always force all vars to be uppercase.
+e.g. `myvar` will be `MYVAR`
+:::
+
+
+
+
## Shelves Manager
You can add your custom shelf set into Houdini by setting your shelf sets, shelves and tools in **Houdini -> Shelves Manager**.

-The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools.
\ No newline at end of file
+The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools.
diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png
new file mode 100644
index 0000000000..74ac8d86c9
Binary files /dev/null and b/website/docs/assets/houdini/update-houdini-vars-context-change.png differ