Merge branch 'develop' into feature/dynamic_modules

This commit is contained in:
iLLiCiTiT 2021-08-04 18:10:37 +02:00
commit cbb7cf8c83
54 changed files with 3741 additions and 190 deletions

View file

@ -1,11 +1,20 @@
# Changelog
## [3.3.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.3.0-nightly.7](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...HEAD)
**🚀 Enhancements**
- Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892)
- Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891)
- Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886)
- TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885)
- Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882)
- Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869)
- Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868)
- Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867)
- Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865)
- Anatomy schema validation [\#1864](https://github.com/pypeclub/OpenPype/pull/1864)
- Ftrack prepare project structure [\#1861](https://github.com/pypeclub/OpenPype/pull/1861)
- Independent general environments [\#1853](https://github.com/pypeclub/OpenPype/pull/1853)
@ -17,9 +26,15 @@
- Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823)
- Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819)
- Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797)
- Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762)
**🐛 Bug fixes**
- Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893)
- global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890)
- publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889)
- Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880)
- Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862)
- imageio: fix grouping [\#1856](https://github.com/pypeclub/OpenPype/pull/1856)
- publisher: missing version in subset prop [\#1849](https://github.com/pypeclub/OpenPype/pull/1849)
- Ftrack type error fix in sync to avalon event handler [\#1845](https://github.com/pypeclub/OpenPype/pull/1845)
@ -28,15 +43,13 @@
- Project folder structure overrides [\#1813](https://github.com/pypeclub/OpenPype/pull/1813)
- Maya: fix yeti settings path in extractor [\#1809](https://github.com/pypeclub/OpenPype/pull/1809)
- Failsafe for cross project containers. [\#1806](https://github.com/pypeclub/OpenPype/pull/1806)
- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803)
- Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802)
**Merged pull requests:**
- Ftrack push attributes action adds traceback to job [\#1842](https://github.com/pypeclub/OpenPype/pull/1842)
- Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space 🚀 [\#1863](https://github.com/pypeclub/OpenPype/pull/1863)
- Add support for pyenv-win on windows [\#1822](https://github.com/pypeclub/OpenPype/pull/1822)
- PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811)
- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808)
## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13)
@ -56,15 +69,13 @@
- Deadline: Nuke submission additional attributes [\#1756](https://github.com/pypeclub/OpenPype/pull/1756)
- Settings schema without prefill [\#1753](https://github.com/pypeclub/OpenPype/pull/1753)
- Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739)
- Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736)
- PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726)
**🐛 Bug fixes**
- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803)
- Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801)
- Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788)
- Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786)
- Otio unrelated error on import [\#1782](https://github.com/pypeclub/OpenPype/pull/1782)
- FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775)
- Fix - single file files are str only, cast it to list to count properly [\#1772](https://github.com/pypeclub/OpenPype/pull/1772)
- Environments in app executable for MacOS [\#1768](https://github.com/pypeclub/OpenPype/pull/1768)
@ -77,14 +88,12 @@
- Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742)
- Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741)
- StandalonePublisher: failing collector for editorial [\#1738](https://github.com/pypeclub/OpenPype/pull/1738)
- Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737)
- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735)
**Merged pull requests:**
- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808)
- Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773)
- Bc/fix/docs [\#1771](https://github.com/pypeclub/OpenPype/pull/1771)
- Expose write attributes to config [\#1770](https://github.com/pypeclub/OpenPype/pull/1770)
- TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755)
## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24)
@ -100,18 +109,10 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3)
**🐛 Bug fixes**
- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727)
## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2)
**🐛 Bug fixes**
- Maya: Extract review hotfix - 2.x backport [\#1713](https://github.com/pypeclub/OpenPype/pull/1713)
## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.1.0-nightly.4...3.1.0)

View file

@ -102,6 +102,11 @@ def install():
.get(platform_name)
) or []
for path in project_plugins:
try:
path = str(path.format(**os.environ))
except KeyError:
pass
if not path or not os.path.exists(path):
continue

View file

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""OpenPype script commands to be used directly in Maya."""
class ToolWindows:
_windows = {}
@classmethod
def get_window(cls, tool):
"""Get widget for specific tool.
Args:
tool (str): Name of the tool.
Returns:
Stored widget.
"""
try:
return cls._windows[tool]
except KeyError:
return None
@classmethod
def set_window(cls, tool, window):
"""Set widget for the tool.
Args:
tool (str): Name of the tool.
window (QtWidgets.QWidget): Widget
"""
cls._windows[tool] = window
def edit_shader_definitions():
from avalon.tools import lib
from Qt import QtWidgets
from openpype.hosts.maya.api.shader_definition_editor import (
ShaderDefinitionsEditor
)
top_level_widgets = QtWidgets.QApplication.topLevelWidgets()
main_window = next(widget for widget in top_level_widgets
if widget.objectName() == "MayaWindow")
with lib.application():
window = ToolWindows.get_window("shader_definition_editor")
if not window:
window = ShaderDefinitionsEditor(parent=main_window)
ToolWindows.set_window("shader_definition_editor", window)
window.show()

View file

@ -6,9 +6,9 @@ from avalon.vendor.Qt import QtWidgets, QtGui
from avalon.maya import pipeline
from openpype.api import BuildWorkfile
import maya.cmds as cmds
from openpype.settings import get_project_settings
self = sys.modules[__name__]
self._menu = os.environ.get("AVALON_LABEL")
log = logging.getLogger(__name__)
@ -17,8 +17,11 @@ log = logging.getLogger(__name__)
def _get_menu(menu_name=None):
"""Return the menu instance if it currently exists in Maya"""
project_settings = get_project_settings(os.getenv("AVALON_PROJECT"))
_menu = project_settings["maya"]["scriptsmenu"]["name"]
if menu_name is None:
menu_name = self._menu
menu_name = _menu
widgets = dict((
w.objectName(), w) for w in QtWidgets.QApplication.allWidgets())
menu = widgets.get(menu_name)
@ -55,35 +58,7 @@ def deferred():
parent=pipeline._parent
)
# Find the pipeline menu
top_menu = _get_menu(pipeline._menu)
# Try to find workfile tool action in the menu
workfile_action = None
for action in top_menu.actions():
if action.text() == "Work Files":
workfile_action = action
break
# Add at the top of menu if "Work Files" action was not found
after_action = ""
if workfile_action:
# Use action's object name for `insertAfter` argument
after_action = workfile_action.objectName()
# Insert action to menu
cmds.menuItem(
"Work Files",
parent=pipeline._menu,
command=launch_workfiles_app,
insertAfter=after_action
)
# Remove replaced action
if workfile_action:
top_menu.removeAction(workfile_action)
log.info("Attempting to install scripts menu..")
log.info("Attempting to install scripts menu ...")
add_build_workfiles_item()
add_look_assigner_item()
@ -100,13 +75,18 @@ def deferred():
return
# load configuration of custom menu
config_path = os.path.join(os.path.dirname(__file__), "menu.json")
config = scriptsmenu.load_configuration(config_path)
project_settings = get_project_settings(os.getenv("AVALON_PROJECT"))
config = project_settings["maya"]["scriptsmenu"]["definition"]
_menu = project_settings["maya"]["scriptsmenu"]["name"]
if not config:
log.warning("Skipping studio menu, no definition found.")
return
# run the launcher for Maya menu
studio_menu = launchformaya.main(
title=self._menu.title(),
objectName=self._menu
title=_menu.title(),
objectName=_menu.title().lower().replace(" ", "_")
)
# apply configuration
@ -116,7 +96,7 @@ def deferred():
def uninstall():
menu = _get_menu()
if menu:
log.info("Attempting to uninstall..")
log.info("Attempting to uninstall ...")
try:
menu.deleteLater()
@ -136,9 +116,8 @@ def install():
def popup():
"""Pop-up the existing menu near the mouse cursor"""
"""Pop-up the existing menu near the mouse cursor."""
menu = _get_menu()
cursor = QtGui.QCursor()
point = cursor.pos()
menu.exec_(point)

View file

@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""Editor for shader definitions.
Shader names are stored as simple text file over GridFS in mongodb.
"""
import os
from Qt import QtWidgets, QtCore, QtGui
from openpype.lib.mongo import OpenPypeMongoConnection
from openpype import resources
import gridfs
DEFINITION_FILENAME = "{}/maya/shader_definition.txt".format(
os.getenv("AVALON_PROJECT"))
class ShaderDefinitionsEditor(QtWidgets.QWidget):
"""Widget serving as simple editor for shader name definitions."""
# name of the file used to store definitions
def __init__(self, parent=None):
super(ShaderDefinitionsEditor, self).__init__(parent)
self._mongo = OpenPypeMongoConnection.get_mongo_client()
self._gridfs = gridfs.GridFS(
self._mongo[os.getenv("OPENPYPE_DATABASE_NAME")])
self._editor = None
self._original_content = self._read_definition_file()
self.setObjectName("shaderDefinitionEditor")
self.setWindowTitle("OpenPype shader name definition editor")
icon = QtGui.QIcon(resources.pype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowFlags(QtCore.Qt.Window)
self.setParent(parent)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.resize(750, 500)
self._setup_ui()
self._reload()
def _setup_ui(self):
"""Setup UI of Widget."""
layout = QtWidgets.QVBoxLayout(self)
label = QtWidgets.QLabel()
label.setText("Put shader names here - one name per line:")
layout.addWidget(label)
self._editor = QtWidgets.QPlainTextEdit()
self._editor.setStyleSheet("border: none;")
layout.addWidget(self._editor)
btn_layout = QtWidgets.QHBoxLayout()
save_btn = QtWidgets.QPushButton("Save")
save_btn.clicked.connect(self._save)
reload_btn = QtWidgets.QPushButton("Reload")
reload_btn.clicked.connect(self._reload)
exit_btn = QtWidgets.QPushButton("Exit")
exit_btn.clicked.connect(self._close)
btn_layout.addWidget(reload_btn)
btn_layout.addWidget(save_btn)
btn_layout.addWidget(exit_btn)
layout.addLayout(btn_layout)
def _read_definition_file(self, file=None):
"""Read definition file from database.
Args:
file (gridfs.grid_file.GridOut, Optional): File to read. If not
set, new query will be issued to find it.
Returns:
str: Content of the file or empty string if file doesn't exist.
"""
content = ""
if not file:
file = self._gridfs.find_one(
{"filename": DEFINITION_FILENAME})
if not file:
print(">>> [SNDE]: nothing in database yet")
return content
content = file.read()
file.close()
return content
def _write_definition_file(self, content, force=False):
"""Write content as definition to file in database.
Before file is writen, check is made if its content has not
changed. If is changed, warning is issued to user if he wants
it to overwrite. Note: GridFs doesn't allow changing file content.
You need to delete existing file and create new one.
Args:
content (str): Content to write.
Raises:
ContentException: If file is changed in database while
editor is running.
"""
file = self._gridfs.find_one(
{"filename": DEFINITION_FILENAME})
if file:
content_check = self._read_definition_file(file)
if content == content_check:
print(">>> [SNDE]: content not changed")
return
if self._original_content != content_check:
if not force:
raise ContentException("Content changed")
print(">>> [SNDE]: overwriting data")
file.close()
self._gridfs.delete(file._id)
file = self._gridfs.new_file(
filename=DEFINITION_FILENAME,
content_type='text/plain',
encoding='utf-8')
file.write(content)
file.close()
QtCore.QTimer.singleShot(200, self._reset_style)
self._editor.setStyleSheet("border: 1px solid #33AF65;")
self._original_content = content
def _reset_style(self):
"""Reset editor style back.
Used to visually indicate save.
"""
self._editor.setStyleSheet("border: none;")
def _close(self):
self.hide()
def closeEvent(self, event):
event.ignore()
self.hide()
def _reload(self):
print(">>> [SNDE]: reloading")
self._set_content(self._read_definition_file())
def _save(self):
try:
self._write_definition_file(content=self._editor.toPlainText())
except ContentException:
# content has changed meanwhile
print(">>> [SNDE]: content has changed")
self._show_overwrite_warning()
def _set_content(self, content):
self._editor.setPlainText(content)
def _show_overwrite_warning(self):
reply = QtWidgets.QMessageBox.question(
self,
"Warning",
("Content you are editing was changed meanwhile in database.\n"
"Please, reload and solve the conflict."),
QtWidgets.QMessageBox.OK)
if reply == QtWidgets.QMessageBox.OK:
# do nothing
pass
class ContentException(Exception):
"""This is risen during save if file is changed in database."""
pass

View file

@ -167,6 +167,8 @@ def get_file_node_path(node):
if cmds.nodeType(node) == 'aiImage':
return cmds.getAttr('{0}.filename'.format(node))
if cmds.nodeType(node) == 'RedshiftNormalMap':
return cmds.getAttr('{}.tex0'.format(node))
# otherwise use fileTextureName
return cmds.getAttr('{0}.fileTextureName'.format(node))
@ -357,6 +359,7 @@ class CollectLook(pyblish.api.InstancePlugin):
files = cmds.ls(history, type="file", long=True)
files.extend(cmds.ls(history, type="aiImage", long=True))
files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True))
self.log.info("Collected file nodes:\n{}".format(files))
# Collect textures if any file nodes are found
@ -487,7 +490,7 @@ class CollectLook(pyblish.api.InstancePlugin):
"""
self.log.debug("processing: {}".format(node))
if cmds.nodeType(node) not in ["file", "aiImage"]:
if cmds.nodeType(node) not in ["file", "aiImage", "RedshiftNormalMap"]:
self.log.error(
"Unsupported file node: {}".format(cmds.nodeType(node)))
raise AssertionError("Unsupported file node")
@ -500,11 +503,19 @@ class CollectLook(pyblish.api.InstancePlugin):
self.log.debug("aiImage node")
attribute = "{}.filename".format(node)
computed_attribute = attribute
elif cmds.nodeType(node) == 'RedshiftNormalMap':
self.log.debug("RedshiftNormalMap node")
attribute = "{}.tex0".format(node)
computed_attribute = attribute
source = cmds.getAttr(attribute)
self.log.info(" - file source: {}".format(source))
color_space_attr = "{}.colorSpace".format(node)
color_space = cmds.getAttr(color_space_attr)
try:
color_space = cmds.getAttr(color_space_attr)
except ValueError:
# node doesn't have colorspace attribute
color_space = "raw"
# Compare with the computed file path, e.g. the one with the <UDIM>
# pattern in it, to generate some logging information about this
# difference

View file

@ -233,11 +233,14 @@ class ExtractLook(openpype.api.Extractor):
for filepath in files_metadata:
linearize = False
if do_maketx and files_metadata[filepath]["color_space"] == "sRGB": # noqa: E501
if do_maketx and files_metadata[filepath]["color_space"].lower() == "srgb": # noqa: E501
linearize = True
# set its file node to 'raw' as tx will be linearized
files_metadata[filepath]["color_space"] = "raw"
if do_maketx:
color_space = "raw"
source, mode, texture_hash = self._process_texture(
filepath,
do_maketx,
@ -280,14 +283,19 @@ class ExtractLook(openpype.api.Extractor):
# This will also trigger in the same order at end of context to
# ensure after context it's still the original value.
color_space_attr = resource["node"] + ".colorSpace"
color_space = cmds.getAttr(color_space_attr)
if files_metadata[source]["color_space"] == "raw":
# set color space to raw if we linearized it
color_space = "Raw"
# Remap file node filename to destination
try:
color_space = cmds.getAttr(color_space_attr)
except ValueError:
# node doesn't have color space attribute
color_space = "raw"
else:
if files_metadata[source]["color_space"] == "raw":
# set color space to raw if we linearized it
color_space = "raw"
# Remap file node filename to destination
remap[color_space_attr] = color_space
attr = resource["attribute"]
remap[attr] = destinations[source]
remap[color_space_attr] = color_space
self.log.info("Finished remapping destinations ...")

View file

@ -1,8 +1,16 @@
# -*- coding: utf-8 -*-
"""Validate model nodes names."""
from maya import cmds
import pyblish.api
import openpype.api
import avalon.api
import openpype.hosts.maya.api.action
from openpype.hosts.maya.api.shader_definition_editor import (
DEFINITION_FILENAME)
from openpype.lib.mongo import OpenPypeMongoConnection
import gridfs
import re
import os
class ValidateModelName(pyblish.api.InstancePlugin):
@ -19,18 +27,18 @@ class ValidateModelName(pyblish.api.InstancePlugin):
families = ["model"]
label = "Model Name"
actions = [openpype.hosts.maya.api.action.SelectInvalidAction]
# path to shader names definitions
# TODO: move it to preset file
material_file = None
regex = '(.*)_(\\d)*_(.*)_(GEO)'
database_file = DEFINITION_FILENAME
@classmethod
def get_invalid(cls, instance):
"""Get invalid nodes."""
use_db = cls.database
# find out if supplied transform is group or not
def is_group(groupName):
def is_group(group_name):
"""Find out if supplied transform is group or not."""
try:
children = cmds.listRelatives(groupName, children=True)
children = cmds.listRelatives(group_name, children=True)
for child in children:
if not cmds.ls(child, transforms=True):
return False
@ -44,29 +52,74 @@ class ValidateModelName(pyblish.api.InstancePlugin):
cls.log.error("Instance has no nodes!")
return True
pass
# validate top level group name
assemblies = cmds.ls(content_instance, assemblies=True, long=True)
if len(assemblies) != 1:
cls.log.error("Must have exactly one top group")
return assemblies or True
top_group = assemblies[0]
regex = cls.top_level_regex
r = re.compile(regex)
m = r.match(top_group)
if m is None:
cls.log.error("invalid name on: {}".format(top_group))
cls.log.error("name doesn't match regex {}".format(regex))
invalid.append(top_group)
else:
if "asset" in r.groupindex:
if m.group("asset") != avalon.api.Session["AVALON_ASSET"]:
cls.log.error("Invalid asset name in top level group.")
return top_group
if "subset" in r.groupindex:
if m.group("subset") != instance.data.get("subset"):
cls.log.error("Invalid subset name in top level group.")
return top_group
if "project" in r.groupindex:
if m.group("project") != avalon.api.Session["AVALON_PROJECT"]:
cls.log.error("Invalid project name in top level group.")
return top_group
descendants = cmds.listRelatives(content_instance,
allDescendents=True,
fullPath=True) or []
descendants = cmds.ls(descendants, noIntermediate=True, long=True)
trns = cmds.ls(descendants, long=False, type=('transform'))
trns = cmds.ls(descendants, long=False, type='transform')
# filter out groups
filter = [node for node in trns if not is_group(node)]
filtered = [node for node in trns if not is_group(node)]
# load shader list file as utf-8
if cls.material_file:
shader_file = open(cls.material_file, "r")
shaders = shader_file.readlines()
shaders = []
if not use_db:
if cls.material_file:
if os.path.isfile(cls.material_file):
shader_file = open(cls.material_file, "r")
shaders = shader_file.readlines()
shader_file.close()
else:
cls.log.error("Missing shader name definition file.")
return True
else:
client = OpenPypeMongoConnection.get_mongo_client()
fs = gridfs.GridFS(client[os.getenv("OPENPYPE_DATABASE_NAME")])
shader_file = fs.find_one({"filename": cls.database_file})
if not shader_file:
cls.log.error("Missing shader name definition in database.")
return True
shaders = shader_file.read().splitlines()
shader_file.close()
# strip line endings from list
shaders = map(lambda s: s.rstrip(), shaders)
# compile regex for testing names
r = re.compile(cls.regex)
regex = cls.regex
r = re.compile(regex)
for obj in filter:
for obj in filtered:
cls.log.info("testing: {}".format(obj))
m = r.match(obj)
if m is None:
cls.log.error("invalid name on: {}".format(obj))
@ -74,7 +127,7 @@ class ValidateModelName(pyblish.api.InstancePlugin):
else:
# if we have shader files and shader named group is in
# regex, test this group against names in shader file
if 'shader' in r.groupindex and shaders:
if "shader" in r.groupindex and shaders:
try:
if not m.group('shader') in shaders:
cls.log.error(
@ -90,8 +143,8 @@ class ValidateModelName(pyblish.api.InstancePlugin):
return invalid
def process(self, instance):
"""Plugin entry point."""
invalid = self.get_invalid(instance)
if invalid:
raise RuntimeError("Model naming is invalid. See log.")
raise RuntimeError("Model naming is invalid. See the log.")

View file

@ -1660,9 +1660,13 @@ def find_free_space_to_paste_nodes(
def launch_workfiles_app():
'''Function letting start workfiles after start of host
'''
# get state from settings
open_at_start = get_current_project_settings()["nuke"].get(
"general", {}).get("open_workfile_at_start")
from openpype.lib import (
env_value_to_bool
)
# get all imortant settings
open_at_start = env_value_to_bool(
env_key="OPENPYPE_WORKFILE_TOOL_ON_START",
default=None)
# return if none is defined
if not open_at_start:

View file

@ -182,7 +182,7 @@ class CollectInstances(pyblish.api.InstancePlugin):
})
for subset, properities in self.subsets.items():
version = properities.get("version")
if version and version == 0:
if version == 0:
properities.pop("version")
# adding Review-able instance

View file

@ -37,7 +37,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin):
# return if any
if entity_type:
return {"entityType": entity_type, "entityName": value}
return {"entity_type": entity_type, "entity_name": value}
def rename_with_hierarchy(self, instance):
search_text = ""
@ -76,8 +76,8 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin):
# add current selection context hierarchy from standalonepublisher
for entity in reversed(visual_hierarchy):
parents.append({
"entityType": entity["data"]["entityType"],
"entityName": entity["name"]
"entity_type": entity["data"]["entityType"],
"entity_name": entity["name"]
})
if self.shot_add_hierarchy:
@ -98,7 +98,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin):
# in case SP context is set to the same folder
if (_index == 0) and ("folder" in parent_key) \
and (parents[-1]["entityName"] == parent_filled):
and (parents[-1]["entity_name"] == parent_filled):
self.log.debug(f" skiping : {parent_filled}")
continue
@ -280,9 +280,9 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin):
for parent in reversed(parents):
next_dict = {}
parent_name = parent["entityName"]
parent_name = parent["entity_name"]
next_dict[parent_name] = {}
next_dict[parent_name]["entity_type"] = parent["entityType"]
next_dict[parent_name]["entity_type"] = parent["entity_type"]
next_dict[parent_name]["childs"] = actual
actual = next_dict

View file

@ -60,7 +60,7 @@ class ExtractTrimVideoAudio(openpype.api.Extractor):
]
args = [
ffmpeg_path,
f"\"{ffmpeg_path}\"",
"-ss", str(start / fps),
"-i", f"\"{video_file_path}\"",
"-t", str(dur / fps)

View file

@ -0,0 +1,22 @@
import pyblish.api
from avalon.tvpaint import workio
from openpype.api import version_up
class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
"""Increment current workfile version."""
order = pyblish.api.IntegratorOrder + 1
label = "Increment Workfile Version"
optional = True
hosts = ["tvpaint"]
def process(self, context):
assert all(result["success"] for result in context.data["results"]), (
"Publishing not succesfull so version is not increased.")
path = context.data["currentFile"]
workio.save_file(version_up(path))
self.log.info('Incrementing workfile version')

View file

@ -1302,10 +1302,18 @@ def _prepare_last_workfile(data, workdir):
)
data["start_last_workfile"] = start_last_workfile
workfile_startup = should_workfile_tool_start(
project_name, app.host_name, task_name
)
data["workfile_startup"] = workfile_startup
# Store boolean as "0"(False) or "1"(True)
data["env"]["AVALON_OPEN_LAST_WORKFILE"] = (
str(int(bool(start_last_workfile)))
)
data["env"]["OPENPYPE_WORKFILE_TOOL_ON_START"] = (
str(int(bool(workfile_startup)))
)
_sub_msg = "" if start_last_workfile else " not"
log.debug(
@ -1344,40 +1352,9 @@ def _prepare_last_workfile(data, workdir):
data["last_workfile_path"] = last_workfile_path
def should_start_last_workfile(
project_name, host_name, task_name, default_output=False
def get_option_from_settings(
startup_presets, host_name, task_name, default_output
):
"""Define if host should start last version workfile if possible.
Default output is `False`. Can be overriden with environment variable
`AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are
`"0", "1", "true", "false", "yes", "no"`.
Args:
project_name (str): Name of project.
host_name (str): Name of host which is launched. In avalon's
application context it's value stored in app definition under
key `"application_dir"`. Is not case sensitive.
task_name (str): Name of task which is used for launching the host.
Task name is not case sensitive.
Returns:
bool: True if host should start workfile.
"""
project_settings = get_project_settings(project_name)
startup_presets = (
project_settings
["global"]
["tools"]
["Workfiles"]
["last_workfile_on_startup"]
)
if not startup_presets:
return default_output
host_name_lowered = host_name.lower()
task_name_lowered = task_name.lower()
@ -1421,6 +1398,82 @@ def should_start_last_workfile(
return default_output
def should_start_last_workfile(
project_name, host_name, task_name, default_output=False
):
"""Define if host should start last version workfile if possible.
Default output is `False`. Can be overriden with environment variable
`AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are
`"0", "1", "true", "false", "yes", "no"`.
Args:
project_name (str): Name of project.
host_name (str): Name of host which is launched. In avalon's
application context it's value stored in app definition under
key `"application_dir"`. Is not case sensitive.
task_name (str): Name of task which is used for launching the host.
Task name is not case sensitive.
Returns:
bool: True if host should start workfile.
"""
project_settings = get_project_settings(project_name)
startup_presets = (
project_settings
["global"]
["tools"]
["Workfiles"]
["last_workfile_on_startup"]
)
if not startup_presets:
return default_output
return get_option_from_settings(
startup_presets, host_name, task_name, default_output)
def should_workfile_tool_start(
project_name, host_name, task_name, default_output=False
):
"""Define if host should start workfile tool at host launch.
Default output is `False`. Can be overriden with environment variable
`OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are
`"0", "1", "true", "false", "yes", "no"`.
Args:
project_name (str): Name of project.
host_name (str): Name of host which is launched. In avalon's
application context it's value stored in app definition under
key `"application_dir"`. Is not case sensitive.
task_name (str): Name of task which is used for launching the host.
Task name is not case sensitive.
Returns:
bool: True if host should start workfile.
"""
project_settings = get_project_settings(project_name)
startup_presets = (
project_settings
["global"]
["tools"]
["Workfiles"]
["open_workfile_tool_on_startup"]
)
if not startup_presets:
return default_output
return get_option_from_settings(
startup_presets, host_name, task_name, default_output)
def compile_list_of_regexes(in_list):
"""Convert strings in entered list to compiled regex objects."""
regexes = list()

View file

@ -11,29 +11,44 @@ from avalon.api import AvalonMongoDB
class AppplicationsAction(BaseAction):
"""Application Action class.
Args:
session (ftrack_api.Session): Session where action will be registered.
label (str): A descriptive string identifing your action.
varaint (str, optional): To group actions together, give them the same
label and specify a unique variant per action.
identifier (str): An unique identifier for app.
description (str): A verbose descriptive text for you action.
icon (str): Url path to icon which will be shown in Ftrack web.
"""
"""Applications Action class."""
type = "Application"
label = "Application action"
identifier = "pype_app.{}.".format(str(uuid4()))
identifier = "openpype_app"
_launch_identifier_with_id = None
icon_url = os.environ.get("OPENPYPE_STATICS_SERVER")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(AppplicationsAction, self).__init__(*args, **kwargs)
self.application_manager = ApplicationManager()
self.dbcon = AvalonMongoDB()
@property
def discover_identifier(self):
if self._discover_identifier is None:
self._discover_identifier = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._discover_identifier
@property
def launch_identifier(self):
if self._launch_identifier is None:
self._launch_identifier = "{}.*".format(self.identifier)
return self._launch_identifier
@property
def launch_identifier_with_id(self):
if self._launch_identifier_with_id is None:
self._launch_identifier_with_id = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._launch_identifier_with_id
def construct_requirements_validations(self):
# Override validation as this action does not need them
return
@ -56,7 +71,7 @@ class AppplicationsAction(BaseAction):
" and data.actionIdentifier={0}"
" and source.user.username={1}"
).format(
self.identifier + "*",
self.launch_identifier,
self.session.api_user
)
self.session.event_hub.subscribe(
@ -136,12 +151,29 @@ class AppplicationsAction(BaseAction):
"label": app.group.label,
"variant": app.label,
"description": None,
"actionIdentifier": self.identifier + app_name,
"actionIdentifier": "{}.{}".format(
self.launch_identifier_with_id, app_name
),
"icon": app_icon
})
return items
def _launch(self, event):
event_identifier = event["data"]["actionIdentifier"]
# Check if identifier is same
# - show message that acion may not be triggered on this machine
if event_identifier.startswith(self.launch_identifier_with_id):
return BaseAction._launch(self, event)
return {
"success": False,
"message": (
"There are running more OpenPype processes"
" where Application can be launched."
)
}
def launch(self, session, entities, event):
"""Callback method for the custom action.
@ -162,7 +194,8 @@ class AppplicationsAction(BaseAction):
*event* the unmodified original event
"""
identifier = event["data"]["actionIdentifier"]
app_name = identifier[len(self.identifier):]
id_identifier_len = len(self.launch_identifier_with_id) + 1
app_name = identifier[id_identifier_len:]
entity = entities[0]

View file

@ -9,16 +9,24 @@ class MultipleNotes(BaseAction):
#: Action label.
label = 'Multiple Notes'
#: Action description.
description = 'Add same note to multiple Asset Versions'
description = 'Add same note to multiple entities'
icon = statics_icon("ftrack", "action_icons", "MultipleNotes.svg")
def discover(self, session, entities, event):
''' Validation '''
valid = True
# Check for multiple selection.
if len(entities) < 2:
valid = False
# Check for valid entities.
valid_entity_types = ['assetversion', 'task']
for entity in entities:
if entity.entity_type.lower() != 'assetversion':
if entity.entity_type.lower() not in valid_entity_types:
valid = False
break
return valid
def interface(self, session, entities, event):
@ -58,7 +66,7 @@ class MultipleNotes(BaseAction):
splitter = {
'type': 'label',
'value': '{}'.format(200*"-")
'value': '{}'.format(200 * "-")
}
items = []

View file

@ -428,9 +428,11 @@ class PrepareProjectLocal(BaseAction):
# Trigger create project structure action
if create_project_structure_checked:
self.trigger_action(
self.create_project_structure_identifier, event
trigger_identifier = "{}.{}".format(
self.create_project_structure_identifier,
self.process_identifier()
)
self.trigger_action(trigger_identifier, event)
return True

View file

@ -24,6 +24,10 @@ class ActionShowWhereIRun(BaseAction):
return False
@property
def launch_identifier(self):
return self.identifier
def launch(self, session, entities, event):
# Don't show info when was launch from this session
if session.event_hub.id == event.get("data", {}).get("event_hub_id"):

View file

@ -29,6 +29,9 @@ class BaseAction(BaseHandler):
icon = None
type = 'Action'
_discover_identifier = None
_launch_identifier = None
settings_frack_subkey = "user_handlers"
settings_enabled_key = "enabled"
@ -42,6 +45,22 @@ class BaseAction(BaseHandler):
super().__init__(session)
@property
def discover_identifier(self):
if self._discover_identifier is None:
self._discover_identifier = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._discover_identifier
@property
def launch_identifier(self):
if self._launch_identifier is None:
self._launch_identifier = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._launch_identifier
def register(self):
'''
Registers the action, subscribing the the discover and launch topics.
@ -60,7 +79,7 @@ class BaseAction(BaseHandler):
' and data.actionIdentifier={0}'
' and source.user.username={1}'
).format(
self.identifier,
self.launch_identifier,
self.session.api_user
)
self.session.event_hub.subscribe(
@ -86,7 +105,7 @@ class BaseAction(BaseHandler):
'label': self.label,
'variant': self.variant,
'description': self.description,
'actionIdentifier': self.identifier,
'actionIdentifier': self.discover_identifier,
'icon': self.icon,
}]
}
@ -309,6 +328,78 @@ class BaseAction(BaseHandler):
return True
class LocalAction(BaseAction):
"""Action that warn user when more Processes with same action are running.
Action is launched all the time but if id does not match id of current
instanace then message is shown to user.
Handy for actions where matters if is executed on specific machine.
"""
_full_launch_identifier = None
@property
def discover_identifier(self):
if self._discover_identifier is None:
self._discover_identifier = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._discover_identifier
@property
def launch_identifier(self):
"""Catch all topics with same identifier."""
if self._launch_identifier is None:
self._launch_identifier = "{}.*".format(self.identifier)
return self._launch_identifier
@property
def full_launch_identifier(self):
"""Catch all topics with same identifier."""
if self._full_launch_identifier is None:
self._full_launch_identifier = "{}.{}".format(
self.identifier, self.process_identifier()
)
return self._full_launch_identifier
def _discover(self, event):
entities = self._translate_event(event)
if not entities:
return
accepts = self.discover(self.session, entities, event)
if not accepts:
return
self.log.debug("Discovering action with selection: {0}".format(
event["data"].get("selection", [])
))
return {
"items": [{
"label": self.label,
"variant": self.variant,
"description": self.description,
"actionIdentifier": self.discover_identifier,
"icon": self.icon,
}]
}
def _launch(self, event):
event_identifier = event["data"]["actionIdentifier"]
# Check if identifier is same
# - show message that acion may not be triggered on this machine
if event_identifier != self.full_launch_identifier:
return {
"success": False,
"message": (
"There are running more OpenPype processes"
" where this action could be launched."
)
}
return super(LocalAction, self)._launch(event)
class ServerAction(BaseAction):
"""Action class meant to be used on event server.
@ -318,6 +409,14 @@ class ServerAction(BaseAction):
settings_frack_subkey = "events"
@property
def discover_identifier(self):
return self.identifier
@property
def launch_identifier(self):
return self.identifier
def register(self):
"""Register subcription to Ftrack event hub."""
self.session.event_hub.subscribe(
@ -328,5 +427,5 @@ class ServerAction(BaseAction):
launch_subscription = (
"topic=ftrack.action.launch and data.actionIdentifier={0}"
).format(self.identifier)
).format(self.launch_identifier)
self.session.event_hub.subscribe(launch_subscription, self._launch)

View file

@ -2,6 +2,7 @@ import os
import tempfile
import json
import functools
import uuid
import datetime
import traceback
import time
@ -36,6 +37,7 @@ class BaseHandler(object):
<description> - a verbose descriptive text for you action
<icon> - icon in ftrack
'''
_process_id = None
# Default priority is 100
priority = 100
# Type is just for logging purpose (e.g.: Action, Event, Application,...)
@ -70,6 +72,13 @@ class BaseHandler(object):
self.register = self.register_decorator(self.register)
self.launch = self.launch_log(self.launch)
@staticmethod
def process_identifier():
"""Helper property to have """
if not BaseHandler._process_id:
BaseHandler._process_id = str(uuid.uuid4())
return BaseHandler._process_id
# Decorator
def register_decorator(self, func):
@functools.wraps(func)

View file

@ -3,6 +3,7 @@ from openpype.api import Logger
log = Logger().get_logger("Event processor")
class TimersManagerModuleRestApi:
"""
REST API endpoint used for calling from hosts when context change
@ -22,6 +23,11 @@ class TimersManagerModuleRestApi:
self.prefix + "/start_timer",
self.start_timer
)
self.server_manager.add_route(
"POST",
self.prefix + "/stop_timer",
self.stop_timer
)
async def start_timer(self, request):
data = await request.json()
@ -38,3 +44,7 @@ class TimersManagerModuleRestApi:
self.module.stop_timers()
self.module.start_timer(project_name, asset_name, task_name, hierarchy)
return Response(status=200)
async def stop_timer(self, request):
self.module.stop_timers()
return Response(status=200)

View file

@ -303,6 +303,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
key_values = {"families": family, "tasks": task_name}
profile = filter_profiles(self.template_name_profiles, key_values,
logger=self.log)
template_name = "publish"
if profile:
template_name = profile["template_name"]

View file

@ -11,7 +11,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin):
"""
order = pyblish.api.ValidatorOrder
label = "Validate Asset Name"
label = "Validate Editorial Asset Name"
def process(self, context):

View file

@ -1,5 +1,13 @@
{
"publish": {
"ValidateEditorialAssetName": {
"enabled": true,
"optional": false
},
"ValidateVersion": {
"enabled": true,
"optional": false
},
"IntegrateHeroVersion": {
"enabled": true,
"optional": true,
@ -260,6 +268,13 @@
"enabled": true
}
],
"open_workfile_tool_on_startup": [
{
"hosts": [],
"tasks": [],
"enabled": false
}
],
"sw_folders": {
"compositing": [
"nuke",

View file

@ -7,6 +7,22 @@
"workfile": "ma",
"yetiRig": "ma"
},
"scriptsmenu": {
"name": "OpenPype Tools",
"definition": [
{
"type": "action",
"command": "import openpype.hosts.maya.api.commands as op_cmds; op_cmds.edit_shader_definitions()",
"sourcetype": "python",
"title": "Edit shader name definitions",
"tooltip": "Edit shader name definitions used in validation and renaming.",
"tags": [
"pipeline",
"shader"
]
}
]
},
"create": {
"CreateLook": {
"enabled": true,
@ -148,12 +164,14 @@
},
"ValidateModelName": {
"enabled": false,
"database": true,
"material_file": {
"windows": "",
"darwin": "",
"linux": ""
},
"regex": "(.*)_(\\\\d)*_(.*)_(GEO)"
"regex": "(.*)_(\\d)*_(?P<shader>.*)_(GEO)",
"top_level_regex": ".*_GRP"
},
"ValidateTransformNamingSuffix": {
"enabled": true,

View file

@ -254,7 +254,7 @@
},
"shot_add_tasks": {}
},
"shot_add_tasks": {
"CollectInstances": {
"custom_start_frame": 0,
"timeline_frame_start": 900000,
"timeline_frame_offset": 0,

View file

@ -144,6 +144,13 @@ class DictConditionalEntity(ItemEntity):
self.enum_entity = None
# GUI attributes
self.enum_is_horizontal = self.schema_data.get(
"enum_is_horizontal", False
)
# `enum_on_right` can be used only if
self.enum_on_right = self.schema_data.get("enum_on_right", False)
self.highlight_content = self.schema_data.get(
"highlight_content", False
)
@ -185,13 +192,13 @@ class DictConditionalEntity(ItemEntity):
children_def_keys = []
for children_def in self.enum_children:
if not isinstance(children_def, dict):
raise EntitySchemaError((
raise EntitySchemaError(self, (
"Children definition under key 'enum_children' must"
" be a dictionary."
))
if "key" not in children_def:
raise EntitySchemaError((
raise EntitySchemaError(self, (
"Children definition under key 'enum_children' miss"
" 'key' definition."
))
@ -286,7 +293,7 @@ class DictConditionalEntity(ItemEntity):
"multiselection": False,
"enum_items": enum_items,
"key": enum_key,
"label": self.enum_label or enum_key
"label": self.enum_label
}
enum_entity = self.create_schema_object(enum_schema, self)

View file

@ -204,6 +204,8 @@
- it is possible to add darker background with `"highlight_content"` (Default: `False`)
- darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color
- output is dictionary `{the "key": children values}`
- for UI porposes was added `enum_is_horizontal` which will make combobox appear next to children inputs instead of on top of them (Default: `False`)
- this has extended ability of `enum_on_right` which will move combobox to right side next to children widgets (Default: `False`)
```
# Example
{

View file

@ -14,6 +14,10 @@
"type": "text"
}
},
{
"type": "schema",
"name": "schema_maya_scriptsmenu"
},
{
"type": "schema",
"name": "schema_maya_create"

View file

@ -327,7 +327,7 @@
{
"type": "dict",
"collapsible": true,
"key": "shot_add_tasks",
"key": "CollectInstances",
"label": "Collect Clip Instances",
"is_group": true,
"children": [

View file

@ -4,6 +4,46 @@
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "dict",
"collapsible": true,
"checkbox_key": "enabled",
"key": "ValidateEditorialAssetName",
"label": "Validate Editorial Asset Name",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
}
]
},
{
"type": "dict",
"collapsible": true,
"checkbox_key": "enabled",
"key": "ValidateVersion",
"label": "Validate Version",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -112,6 +112,41 @@
]
}
},
{
"type": "list",
"key": "open_workfile_tool_on_startup",
"label": "Open workfile tool on launch",
"is_group": true,
"use_label_wrap": true,
"object_type": {
"type": "dict",
"children": [
{
"type": "hosts-enum",
"key": "hosts",
"label": "Hosts",
"multiselection": true,
"hosts_filter": [
"nuke"
]
},
{
"key": "tasks",
"label": "Tasks",
"type": "list",
"object_type": "text"
},
{
"type": "splitter"
},
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
}
]
}
},
{
"type": "dict-modifiable",
"collapsible": true,

View file

@ -147,9 +147,14 @@
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "database",
"label": "Use database shader name definitions"
},
{
"type": "label",
"label": "Path to material file defining list of material names to check. This is material name per line simple text file.<br/>It will be checked against named group <b>shader</b> in your <em>Validation regex</em>.<p>For example: <br/> <code>^.*(?P=&lt;shader&gt;.+)_GEO</code></p>"
"label": "Path to material file defining list of material names to check. This is material name per line simple text file.<br/>It will be checked against named group <b>shader</b> in your <em>Validation regex</em>.<p>For example: <br/> <code>^.*(?P=&lt;shader&gt;.+)_GEO</code></p>This is used instead of database definitions if they are disabled."
},
{
"type": "path",
@ -162,6 +167,15 @@
"type": "text",
"key": "regex",
"label": "Validation regex"
},
{
"type": "label",
"label": "Regex for validating name of top level group name.<br/>You can use named capturing groups:<br/><code>(?P&lt;asset&gt;.*)</code> for Asset name<br/><code>(?P&lt;subset&gt;.*)</code> for Subset<br/><code>(?P&lt;project&gt;.*)</code> for project<br/><p>For example to check for asset in name so <code>*_some_asset_name_GRP</code> is valid, use:<br/><code>.*?_(?P&lt;asset&gt;.*)_GEO</code>"
},
{
"type": "text",
"key": "top_level_regex",
"label": "Top level group name regex"
}
]
},

View file

@ -0,0 +1,22 @@
{
"type": "dict",
"collapsible": true,
"key": "scriptsmenu",
"label": "Scripts Menu Definition",
"children": [
{
"type": "text",
"key": "name",
"label": "Menu Name"
},
{
"type": "splitter"
},
{
"type": "raw-json",
"key": "definition",
"label": "Menu definition",
"is_list": true
}
]
}

View file

@ -9,6 +9,31 @@
"label": "Color input",
"type": "color"
},
{
"type": "dict-conditional",
"key": "overriden_value",
"label": "Overriden value",
"enum_key": "overriden",
"enum_is_horizontal": true,
"enum_children": [
{
"key": "overriden",
"label": "Override value",
"children": [
{
"type": "number",
"key": "value",
"label": "value"
}
]
},
{
"key": "inherit",
"label": "Inherit value",
"children": []
}
]
},
{
"type": "dict-conditional",
"use_label_wrap": true,

View file

@ -294,6 +294,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
msg = "<br><br>".join(warnings)
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle("Save warnings")
dialog.setText(msg)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.exec_()
@ -303,6 +304,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
except Exception as exc:
formatted_traceback = traceback.format_exception(*sys.exc_info())
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle("Unexpected error")
msg = "Unexpected error happened!\n\nError: {}".format(str(exc))
dialog.setText(msg)
dialog.setDetailedText("\n".join(formatted_traceback))
@ -392,6 +394,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
except Exception as exc:
formatted_traceback = traceback.format_exception(*sys.exc_info())
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle("Unexpected error")
msg = "Unexpected error happened!\n\nError: {}".format(str(exc))
dialog.setText(msg)
dialog.setDetailedText("\n".join(formatted_traceback))

View file

@ -24,6 +24,7 @@ class DictConditionalWidget(BaseWidget):
self.body_widget = None
self.content_widget = None
self.content_layout = None
self.enum_layout = None
label = None
if self.entity.is_dynamic_item:
@ -40,8 +41,36 @@ class DictConditionalWidget(BaseWidget):
self._enum_key_by_wrapper_id = {}
self._added_wrapper_ids = set()
self.content_layout.setColumnStretch(0, 0)
self.content_layout.setColumnStretch(1, 1)
enum_layout = QtWidgets.QGridLayout()
enum_layout.setContentsMargins(0, 0, 0, 0)
enum_layout.setColumnStretch(0, 0)
enum_layout.setColumnStretch(1, 1)
all_children_layout = QtWidgets.QVBoxLayout()
all_children_layout.setContentsMargins(0, 0, 0, 0)
if self.entity.enum_is_horizontal:
if self.entity.enum_on_right:
self.content_layout.addLayout(all_children_layout, 0, 0)
self.content_layout.addLayout(enum_layout, 0, 1)
# Stretch combobox to minimum and expand value
self.content_layout.setColumnStretch(0, 1)
self.content_layout.setColumnStretch(1, 0)
else:
self.content_layout.addLayout(enum_layout, 0, 0)
self.content_layout.addLayout(all_children_layout, 0, 1)
# Stretch combobox to minimum and expand value
self.content_layout.setColumnStretch(0, 0)
self.content_layout.setColumnStretch(1, 1)
else:
# Expand content
self.content_layout.setColumnStretch(0, 1)
self.content_layout.addLayout(enum_layout, 0, 0)
self.content_layout.addLayout(all_children_layout, 1, 0)
self.enum_layout = enum_layout
self.all_children_layout = all_children_layout
# Add enum entity to layout mapping
enum_entity = self.entity.enum_entity
@ -58,6 +87,8 @@ class DictConditionalWidget(BaseWidget):
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(5)
all_children_layout.addWidget(content_widget)
self._content_by_enum_value[enum_key] = {
"widget": content_widget,
"layout": content_layout
@ -80,9 +111,6 @@ class DictConditionalWidget(BaseWidget):
for item_key, children in self.entity.children.items():
content_widget = self._content_by_enum_value[item_key]["widget"]
row = self.content_layout.rowCount()
self.content_layout.addWidget(content_widget, row, 0, 1, 2)
for child_obj in children:
self.input_fields.append(
self.create_ui_for_entity(
@ -191,12 +219,25 @@ class DictConditionalWidget(BaseWidget):
else:
map_id = widget.entity.id
content_widget = self.content_widget
content_layout = self.content_layout
if map_id != self.entity.enum_entity.id:
enum_value = self._enum_key_by_wrapper_id[map_id]
content_widget = self._content_by_enum_value[enum_value]["widget"]
content_layout = self._content_by_enum_value[enum_value]["layout"]
is_enum_item = map_id == self.entity.enum_entity.id
if is_enum_item:
content_widget = self.content_widget
content_layout = self.enum_layout
if not label:
content_layout.addWidget(widget, 0, 0, 1, 2)
return
label_widget = GridLabelWidget(label, widget)
label_widget.input_field = widget
widget.label_widget = label_widget
content_layout.addWidget(label_widget, 0, 0, 1, 1)
content_layout.addWidget(widget, 0, 1, 1, 1)
return
enum_value = self._enum_key_by_wrapper_id[map_id]
content_widget = self._content_by_enum_value[enum_value]["widget"]
content_layout = self._content_by_enum_value[enum_value]["layout"]
wrapper = self._parent_widget_by_entity_id[map_id]
if wrapper is not content_widget:

View file

@ -94,7 +94,8 @@ class MainWidget(QtWidgets.QWidget):
super(MainWidget, self).showEvent(event)
if self._reset_on_show:
self._reset_on_show = False
self.reset()
# Trigger reset with 100ms delay
QtCore.QTimer.singleShot(100, self.reset)
def _show_password_dialog(self):
if self._password_dialog:
@ -107,6 +108,8 @@ class MainWidget(QtWidgets.QWidget):
self._password_dialog = None
if password_passed:
self.reset()
if not self.isVisible():
self.show()
else:
self.close()

View file

@ -693,16 +693,16 @@ class FilesWidget(QtWidgets.QWidget):
)
return
file_path = os.path.join(self.root, work_file)
file_path = os.path.join(os.path.normpath(self.root), work_file)
pipeline.emit("before.workfile.save", file_path)
pipeline.emit("before.workfile.save", [file_path])
self._enter_session() # Make sure we are in the right session
self.host.save_file(file_path)
self.set_asset_task(self._asset, self._task)
pipeline.emit("after.workfile.save", file_path)
pipeline.emit("after.workfile.save", [file_path])
self.workfile_created.emit(file_path)

View file

@ -0,0 +1,5 @@
from .scriptsmenu import ScriptsMenu
from . import version
__all__ = ["ScriptsMenu"]
__version__ = version.version

View file

@ -0,0 +1,207 @@
import os
from .vendor.Qt import QtWidgets
class Action(QtWidgets.QAction):
"""Custom Action widget"""
def __init__(self, parent=None):
QtWidgets.QAction.__init__(self, parent)
self._root = None
self._tags = list()
self._command = None
self._sourcetype = None
self._iconfile = None
self._label = None
self._COMMAND = """import imp
f, filepath, descr = imp.find_module('{module_name}', ['{dirname}'])
module = imp.load_module('{module_name}', f, filepath, descr)
module.{module_name}()"""
@property
def root(self):
return self._root
@root.setter
def root(self, value):
self._root = value
@property
def tags(self):
return self._tags
@tags.setter
def tags(self, value):
self._tags = value
@property
def command(self):
return self._command
@command.setter
def command(self, value):
"""
Store the command in the QAction
Args:
value (str): the full command which will be executed when clicked
Return:
None
"""
self._command = value
@property
def sourcetype(self):
return self._sourcetype
@sourcetype.setter
def sourcetype(self, value):
"""
Set the command type to get the correct execution of the command given
Args:
value (str): the name of the command type
Returns:
None
"""
self._sourcetype = value
@property
def iconfile(self):
return self._iconfile
@iconfile.setter
def iconfile(self, value):
"""Store the path to the image file which needs to be displayed
Args:
value (str): the path to the image
Returns:
None
"""
self._iconfile = value
@property
def label(self):
return self._label
@label.setter
def label(self, value):
"""
Set the abbreviation which will be used as overlay text in the shelf
Args:
value (str): an abbreviation of the name
Returns:
None
"""
self._label = value
def run_command(self):
"""
Run the command of the instance or copy the command to the active shelf
based on the current modifiers.
If callbacks have been registered with fouind modifier integer the
function will trigger all callbacks. When a callback function returns a
non zero integer it will not execute the action's command
"""
# get the current application and its linked keyboard modifiers
modifiers = QtWidgets.QApplication.keyboardModifiers()
# If the menu has a callback registered for the current modifier
# we run the callback instead of the action itself.
registered = self._root.registered_callbacks
callbacks = registered.get(int(modifiers), [])
for callback in callbacks:
signal = callback(self)
if signal != 0:
# Exit function on non-zero return code
return
exec(self.process_command())
def process_command(self):
"""
Check if the command is a file which needs to be launched and if it
has a relative path, if so return the full path by expanding
environment variables. Wrap any mel command in a executable string
for Python and return the string if the source type is
Add your own source type and required process to ensure callback
is stored correctly.
An example of a process is the sourcetype is MEL
(Maya Embedded Language) as Python cannot run it on its own so it
needs to be wrapped in a string in which we explicitly import mel and
run it as a mel.eval. The string is then parsed to python as
exec("command").
Returns:
str: a clean command which can be used
"""
if self._sourcetype == "python":
return self._command
if self._sourcetype == "mel":
# Escape single quotes
conversion = self._command.replace("'", "\\'")
return "import maya; maya.mel.eval('{}')".format(conversion)
if self._sourcetype == "file":
if os.path.isabs(self._command):
filepath = self._command
else:
filepath = os.path.normpath(os.path.expandvars(self._command))
return self._wrap_filepath(filepath)
def has_tag(self, tag):
"""Check whether the tag matches with the action's tags.
A partial match will also return True, for example tag `a` will match
correctly with the `ape` tag on the Action.
Args:
tag (str): The tag
Returns
bool: Whether the action is tagged with given tag
"""
for tagitem in self.tags:
if tag not in tagitem:
continue
return True
return False
def _wrap_filepath(self, file_path):
"""Create a wrapped string for the python command
Args:
file_path (str): the filepath of a script
Returns:
str: the wrapped command
"""
dirname = os.path.dirname(r"{}".format(file_path))
dirpath = dirname.replace("\\", "/")
module_name = os.path.splitext(os.path.basename(file_path))[0]
return self._COMMAND.format(module_name=module_name, dirname=dirpath)

View file

@ -0,0 +1,54 @@
# Import third-party modules
from vendor.Qt import QtWidgets
# Import local modules
import scriptsmenu
def _mari_main_window():
"""Get Mari main window.
Returns:
MriMainWindow: Mari's main window.
"""
for obj in QtWidgets.QApplication.topLevelWidgets():
if obj.metaObject().className() == 'MriMainWindow':
return obj
raise RuntimeError('Could not find Mari MainWindow instance')
def _mari_main_menubar():
"""Get Mari main menu bar.
Returns:
Retrieve the main menubar of the Mari window.
"""
mari_window = _mari_main_window()
menubar = [
i for i in mari_window.children() if isinstance(i, QtWidgets.QMenuBar)
]
assert len(menubar) == 1, "Error, could not find menu bar!"
return menubar[0]
def main(title="Scripts"):
"""Build the main scripts menu in Mari.
Args:
title (str): Name of the menu in the application.
Returns:
scriptsmenu.ScriptsMenu: Instance object.
"""
mari_main_bar = _mari_main_menubar()
for mari_bar in mari_main_bar.children():
if isinstance(mari_bar, scriptsmenu.ScriptsMenu):
if mari_bar.title() == title:
menu = mari_bar
return menu
menu = scriptsmenu.ScriptsMenu(title=title, parent=mari_main_bar)
return menu

View file

@ -0,0 +1,137 @@
import logging
import maya.cmds as cmds
import maya.mel as mel
import scriptsmenu
from .vendor.Qt import QtCore, QtWidgets
log = logging.getLogger(__name__)
def register_repeat_last(action):
"""Register the action in repeatLast to ensure the RepeatLast hotkey works
Args:
action (action.Action): Action wigdet instance
Returns:
int: 0
"""
command = action.process_command()
command = command.replace("\n", "; ")
# Register command to Maya (mel)
cmds.repeatLast(addCommand='python("{}")'.format(command),
addCommandLabel=action.label)
return 0
def to_shelf(action):
"""Copy clicked menu item to the currently active Maya shelf
Args:
action (action.Action): the action instance which is clicked
Returns:
int: 1
"""
shelftoplevel = mel.eval("$gShelfTopLevel = $gShelfTopLevel;")
current_active_shelf = cmds.tabLayout(shelftoplevel,
query=True,
selectTab=True)
cmds.shelfButton(command=action.process_command(),
sourceType="python",
parent=current_active_shelf,
image=action.iconfile or "pythonFamily.png",
annotation=action.statusTip(),
imageOverlayLabel=action.label or "")
return 1
def _maya_main_window():
"""Return Maya's main window"""
for obj in QtWidgets.QApplication.topLevelWidgets():
if obj.objectName() == 'MayaWindow':
return obj
raise RuntimeError('Could not find MayaWindow instance')
def _maya_main_menubar():
"""Retrieve the main menubar of the Maya window"""
mayawindow = _maya_main_window()
menubar = [i for i in mayawindow.children()
if isinstance(i, QtWidgets.QMenuBar)]
assert len(menubar) == 1, "Error, could not find menu bar!"
return menubar[0]
def find_scripts_menu(title, parent):
"""
Check if the menu exists with the given title in the parent
Args:
title (str): the title name of the scripts menu
parent (QtWidgets.QMenuBar): the menubar to check
Returns:
QtWidgets.QMenu or None
"""
menu = None
search = [i for i in parent.children() if
isinstance(i, scriptsmenu.ScriptsMenu)
and i.title() == title]
if search:
assert len(search) < 2, ("Multiple instances of menu '{}' "
"in menu bar".format(title))
menu = search[0]
return menu
def main(title="Scripts", parent=None, objectName=None):
"""Build the main scripts menu in Maya
Args:
title (str): name of the menu in the application
parent (QtWidgets.QtMenuBar): the parent object for the menu
objectName (str): custom objectName for scripts menu
Returns:
scriptsmenu.ScriptsMenu instance
"""
mayamainbar = parent or _maya_main_menubar()
try:
# check menu already exists
menu = find_scripts_menu(title, mayamainbar)
if not menu:
log.info("Attempting to build menu ...")
object_name = objectName or title.lower()
menu = scriptsmenu.ScriptsMenu(title=title,
parent=mayamainbar,
objectName=object_name)
except Exception as e:
log.error(e)
return
# Register control + shift callback to add to shelf (maya behavior)
modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
menu.register_callback(int(modifiers), to_shelf)
menu.register_callback(0, register_repeat_last)
return menu

View file

@ -0,0 +1,36 @@
import scriptsmenu
from .vendor.Qt import QtWidgets
def _nuke_main_window():
"""Return Nuke's main window"""
for obj in QtWidgets.QApplication.topLevelWidgets():
if (obj.inherits('QMainWindow') and
obj.metaObject().className() == 'Foundry::UI::DockMainWindow'):
return obj
raise RuntimeError('Could not find Nuke MainWindow instance')
def _nuke_main_menubar():
"""Retrieve the main menubar of the Nuke window"""
nuke_window = _nuke_main_window()
menubar = [i for i in nuke_window.children()
if isinstance(i, QtWidgets.QMenuBar)]
assert len(menubar) == 1, "Error, could not find menu bar!"
return menubar[0]
def main(title="Scripts"):
# Register control + shift callback to add to shelf (Nuke behavior)
# modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
# menu.register_callback(modifiers, to_shelf)
nuke_main_bar = _nuke_main_menubar()
for nuke_bar in nuke_main_bar.children():
if isinstance(nuke_bar, scriptsmenu.ScriptsMenu):
if nuke_bar.title() == title:
menu = nuke_bar
return menu
menu = scriptsmenu.ScriptsMenu(title=title, parent=nuke_main_bar)
return menu

View file

@ -0,0 +1,316 @@
import os
import json
import logging
from collections import defaultdict
from .vendor.Qt import QtWidgets, QtCore
from . import action
log = logging.getLogger(__name__)
class ScriptsMenu(QtWidgets.QMenu):
"""A Qt menu that displays a list of searchable actions"""
updated = QtCore.Signal(QtWidgets.QMenu)
def __init__(self, *args, **kwargs):
"""Initialize Scripts menu
Args:
title (str): the name of the root menu which will be created
parent (QtWidgets.QObject) : the QObject to parent the menu to
Returns:
None
"""
QtWidgets.QMenu.__init__(self, *args, **kwargs)
self.searchbar = None
self.update_action = None
self._script_actions = []
self._callbacks = defaultdict(list)
# Automatically add it to the parent menu
parent = kwargs.get("parent", None)
if parent:
parent.addMenu(self)
objectname = kwargs.get("objectName", "scripts")
title = kwargs.get("title", "Scripts")
self.setObjectName(objectname)
self.setTitle(title)
# add default items in the menu
self.create_default_items()
def on_update(self):
self.updated.emit(self)
@property
def registered_callbacks(self):
return self._callbacks.copy()
def create_default_items(self):
"""Add a search bar to the top of the menu given"""
# create widget and link function
searchbar = QtWidgets.QLineEdit()
searchbar.setFixedWidth(120)
searchbar.setPlaceholderText("Search ...")
searchbar.textChanged.connect(self._update_search)
self.searchbar = searchbar
# create widget holder
searchbar_action = QtWidgets.QWidgetAction(self)
# add widget to widget holder
searchbar_action.setDefaultWidget(self.searchbar)
searchbar_action.setObjectName("Searchbar")
# add update button and link function
update_action = QtWidgets.QAction(self)
update_action.setObjectName("Update Scripts")
update_action.setText("Update Scripts")
update_action.setVisible(False)
update_action.triggered.connect(self.on_update)
self.update_action = update_action
# add action to menu
self.addAction(searchbar_action)
self.addAction(update_action)
# add separator object
separator = self.addSeparator()
separator.setObjectName("separator")
def add_menu(self, title, parent=None):
"""Create a sub menu for a parent widget
Args:
parent(QtWidgets.QWidget): the object to parent the menu to
title(str): the title of the menu
Returns:
QtWidget.QMenu instance
"""
if not parent:
parent = self
menu = QtWidgets.QMenu(parent, title)
menu.setTitle(title)
menu.setObjectName(title)
menu.setTearOffEnabled(True)
parent.addMenu(menu)
return menu
def add_script(self, parent, title, command, sourcetype, icon=None,
tags=None, label=None, tooltip=None):
"""Create an action item which runs a script when clicked
Args:
parent (QtWidget.QWidget): The widget to parent the item to
title (str): The text which will be displayed in the menu
command (str): The command which needs to be run when the item is
clicked.
sourcetype (str): The type of command, the way the command is
processed is based on the source type.
icon (str): The file path of an icon to display with the menu item
tags (list, tuple): Keywords which describe the action
label (str): A short description of the script which will be displayed
when hovering over the menu item
tooltip (str): A tip for the user about the usage fo the tool
Returns:
QtWidget.QAction instance
"""
assert tags is None or isinstance(tags, (list, tuple))
# Ensure tags is a list
tags = list() if tags is None else list(tags)
tags.append(title.lower())
assert icon is None or isinstance(icon, str), (
"Invalid data type for icon, supported : None, string")
# create new action
script_action = action.Action(parent)
script_action.setText(title)
script_action.setObjectName(title)
script_action.tags = tags
# link action to root for callback library
script_action.root = self
# Set up the command
script_action.sourcetype = sourcetype
script_action.command = command
try:
script_action.process_command()
except RuntimeError as e:
raise RuntimeError("Script action can't be "
"processed: {}".format(e))
if icon:
iconfile = os.path.expandvars(icon)
script_action.iconfile = iconfile
script_action_icon = QtWidgets.QIcon(iconfile)
script_action.setIcon(script_action_icon)
if label:
script_action.label = label
if tooltip:
script_action.setStatusTip(tooltip)
script_action.triggered.connect(script_action.run_command)
parent.addAction(script_action)
# Add to our searchable actions
self._script_actions.append(script_action)
return script_action
def build_from_configuration(self, parent, configuration):
"""Process the configurations and store the configuration
This creates all submenus from a configuration.json file.
When the configuration holds the key `main` all scripts under `main` will
be added to the main menu first before adding the rest
Args:
parent (ScriptsMenu): script menu instance
configuration (list): A ScriptsMenu configuration list
Returns:
None
"""
for item in configuration:
assert isinstance(item, dict), "Configuration is wrong!"
# skip items which have no `type` key
item_type = item.get('type', None)
if not item_type:
log.warning("Missing 'type' from configuration item")
continue
# add separator
# Special behavior for separators
if item_type == "separator":
parent.addSeparator()
# add submenu
# items should hold a collection of submenu items (dict)
elif item_type == "menu":
assert "items" in item, "Menu is missing 'items' key"
menu = self.add_menu(parent=parent, title=item["title"])
self.build_from_configuration(menu, item["items"])
# add script
elif item_type == "action":
# filter out `type` from the item dict
config = {key: value for key, value in
item.items() if key != "type"}
self.add_script(parent=parent, **config)
def set_update_visible(self, state):
self.update_action.setVisible(state)
def clear_menu(self):
"""Clear all menu items which are not default
Returns:
None
"""
# TODO: Set up a more robust implementation for this
# Delete all except the first three actions
for _action in self.actions()[3:]:
self.removeAction(_action)
def register_callback(self, modifiers, callback):
self._callbacks[modifiers].append(callback)
def _update_search(self, search):
"""Hide all the samples which do not match the user's import
Returns:
None
"""
if not search:
for action in self._script_actions:
action.setVisible(True)
else:
for action in self._script_actions:
if not action.has_tag(search.lower()):
action.setVisible(False)
# Set visibility for all submenus
for action in self.actions():
if not action.menu():
continue
menu = action.menu()
visible = any(action.isVisible() for action in menu.actions())
action.setVisible(visible)
def load_configuration(path):
"""Load the configuration from a file
Read out the JSON file which will dictate the structure of the scripts menu
Args:
path (str): file path of the .JSON file
Returns:
dict
"""
if not os.path.isfile(path):
raise AttributeError("Given configuration is not "
"a file!\n'{}'".format(path))
extension = os.path.splitext(path)[-1]
if extension != ".json":
raise AttributeError("Given configuration file has unsupported "
"file type, provide a .json file")
# retrieve and store config
with open(path, "r") as f:
configuration = json.load(f)
return configuration
def application(configuration, parent):
import sys
app = QtWidgets.QApplication(sys.argv)
scriptsmenu = ScriptsMenu(configuration, parent)
scriptsmenu.show()
sys.exit(app.exec_())

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
VERSION_MAJOR = 1
VERSION_MINOR = 5
VERSION_PATCH = 1
version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)
__version__ = version
__all__ = ['version', '__version__']

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.3.0-nightly.5"
__version__ = "3.3.0-nightly.7"

View file

@ -80,17 +80,6 @@ function Show-PSWarning() {
}
}
function Install-Poetry() {
Write-Host ">>> " -NoNewline -ForegroundColor Green
Write-Host "Installing Poetry ... "
$python = "python"
if (Get-Command "pyenv" -ErrorAction SilentlyContinue) {
$python = & pyenv which python
}
$env:POETRY_HOME="$openpype_root\.poetry"
(Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | & $($python) -
}
$art = @"
. . .. . ..

View file

@ -62,9 +62,12 @@ function Test-Python() {
Write-Host "Detecting host Python ... " -NoNewline
$python = "python"
if (Get-Command "pyenv" -ErrorAction SilentlyContinue) {
$python = & pyenv which python
$pyenv_python = & pyenv which python
if (Test-Path -PathType Leaf -Path "$($pyenv_python)") {
$python = $pyenv_python
}
}
if (-not (Get-Command "python3" -ErrorAction SilentlyContinue)) {
if (-not (Get-Command $python -ErrorAction SilentlyContinue)) {
Write-Host "!!! Python not detected" -ForegroundColor red
Set-Location -Path $current_dir
Exit-WithCode 1

View file

@ -4,11 +4,11 @@ title: Maya
sidebar_label: Maya
---
## Maya
## Publish Plugins
### Publish Plugins
### Render Settings Validator
#### Render Settings Validator (`ValidateRenderSettings`)
`ValidateRenderSettings`
Render Settings Validator is here to make sure artists will submit renders
we correct settings. Some of these settings are needed by OpenPype but some
@ -49,4 +49,49 @@ Arnolds Camera (AA) samples to 6.
Note that `aiOptions` is not the name of node but rather its type. For renderers there is usually
just one instance of this node type but if that is not so, validator will go through all its
instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman**
it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`.
it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`.
### Model Name Validator
`ValidateRenderSettings`
This validator can enforce specific names for model members. It will check them against **Validation Regex**.
There is special group in that regex - **shader**. If present, it will take that part of the name as shader name
and it will compare it with list of shaders defined either in file name specified in **Material File** or from
database file that is per project and can be directly edited from Maya's *OpenPype Tools > Edit Shader name definitions* when
**Use database shader name definitions** is on. This list defines simply as one shader name per line.
![Settings example](assets/maya-admin_model_name_validator.png)
For example - you are using default regex `(.*)_(\d)*_(?P<shader>.*)_(GEO)` and you have two shaders defined
in either file or database `foo` and `bar`.
Object named `SomeCube_0001_foo_GEO` will pass but `SomeCube_GEO` will not and `SomeCube_001_xxx_GEO` will not too.
#### Top level group name
There is a validation for top level group name too. You can specify whatever regex you'd like to use. Default will
pass everything with `_GRP` suffix. You can use *named capturing groups* to validate against specific data. If you
put `(?P<asset>.*)` it will try to match everything captured in that group against current asset name. Likewise you can
use it for **subset** and **project** - `(?P<subset>.*)` and `(?P<project>.*)`.
**Example**
You are working on asset (shot) `0030_OGC_0190`. You have this regex in **Top level group name**:
```regexp
.*?_(?P<asset>.*)_GRP
```
When you publish your model with top group named like `foo_GRP` it will fail. But with `foo_0030_OGC_0190_GRP` it will pass.
:::info About regex
All regexes used here are in Python variant.
:::
## Custom Menu
You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**.
![Custom menu definition](assets/maya-admin_scriptsmenu.png)
:::note Work in progress
This is still work in progress. Menu definition will be handled more friendly with widgets and not
raw json.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB