mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-26 13:52:15 +01:00
Merge branch 'develop' into feature/creator_in_openpype
This commit is contained in:
commit
66812f01be
28 changed files with 2183 additions and 527 deletions
|
|
@ -158,7 +158,9 @@ def extractenvironments(output_json_path, project, asset, task, app):
|
|||
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
|
||||
@click.option("-t", "--targets", help="Targets module", default=None,
|
||||
multiple=True)
|
||||
def publish(debug, paths, targets):
|
||||
@click.option("-g", "--gui", is_flag=True,
|
||||
help="Show Publish UI", default=False)
|
||||
def publish(debug, paths, targets, gui):
|
||||
"""Start CLI publishing.
|
||||
|
||||
Publish collects json from paths provided as an argument.
|
||||
|
|
@ -166,7 +168,7 @@ def publish(debug, paths, targets):
|
|||
"""
|
||||
if debug:
|
||||
os.environ['OPENPYPE_DEBUG'] = '3'
|
||||
PypeCommands.publish(list(paths), targets)
|
||||
PypeCommands.publish(list(paths), targets, gui)
|
||||
|
||||
|
||||
@main.command()
|
||||
|
|
|
|||
|
|
@ -412,6 +412,14 @@ class FtrackModule(
|
|||
hours_logged = (task_entity["time_logged"] / 60) / 60
|
||||
return hours_logged
|
||||
|
||||
def get_credentials(self):
|
||||
# type: () -> tuple
|
||||
"""Get local Ftrack credentials."""
|
||||
from .lib import credentials
|
||||
|
||||
cred = credentials.get_credentials(self.ftrack_url)
|
||||
return cred.get("username"), cred.get("api_key")
|
||||
|
||||
def cli(self, click_group):
|
||||
click_group.add_command(cli_main)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Collect default Deadline server."""
|
||||
import pyblish.api
|
||||
import os
|
||||
|
||||
|
||||
class CollectLocalFtrackCreds(pyblish.api.ContextPlugin):
|
||||
"""Collect default Royal Render path."""
|
||||
|
||||
order = pyblish.api.CollectorOrder + 0.01
|
||||
label = "Collect local ftrack credentials"
|
||||
targets = ["rr_control"]
|
||||
|
||||
def process(self, context):
|
||||
if os.getenv("FTRACK_API_USER") and os.getenv("FTRACK_API_KEY") and \
|
||||
os.getenv("FTRACK_SERVER"):
|
||||
return
|
||||
ftrack_module = context.data["openPypeModules"]["ftrack"]
|
||||
if ftrack_module.enabled:
|
||||
creds = ftrack_module.get_credentials()
|
||||
os.environ["FTRACK_API_USER"] = creds[0]
|
||||
os.environ["FTRACK_API_KEY"] = creds[1]
|
||||
os.environ["FTRACK_SERVER"] = ftrack_module.ftrack_url
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from .royal_render_module import RoyalRenderModule
|
||||
|
||||
|
||||
__all__ = (
|
||||
"RoyalRenderModule",
|
||||
)
|
||||
199
openpype/modules/default_modules/royal_render/api.py
Normal file
199
openpype/modules/default_modules/royal_render/api.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Wrapper around Royal Render API."""
|
||||
import sys
|
||||
import os
|
||||
|
||||
from openpype.settings import get_project_settings
|
||||
from openpype.lib.local_settings import OpenPypeSettingsRegistry
|
||||
from openpype.lib import PypeLogger, run_subprocess
|
||||
from .rr_job import RRJob, SubmitFile, SubmitterParameter
|
||||
|
||||
|
||||
log = PypeLogger.get_logger("RoyalRender")
|
||||
|
||||
|
||||
class Api:
|
||||
|
||||
_settings = None
|
||||
RR_SUBMIT_CONSOLE = 1
|
||||
RR_SUBMIT_API = 2
|
||||
|
||||
def __init__(self, settings, project=None):
|
||||
self._settings = settings
|
||||
self._initialize_rr(project)
|
||||
|
||||
def _initialize_rr(self, project=None):
|
||||
# type: (str) -> None
|
||||
"""Initialize RR Path.
|
||||
|
||||
Args:
|
||||
project (str, Optional): Project name to set RR api in
|
||||
context.
|
||||
|
||||
"""
|
||||
if project:
|
||||
project_settings = get_project_settings(project)
|
||||
rr_path = (
|
||||
project_settings
|
||||
["royalrender"]
|
||||
["rr_paths"]
|
||||
)
|
||||
else:
|
||||
rr_path = (
|
||||
self._settings
|
||||
["modules"]
|
||||
["royalrender"]
|
||||
["rr_path"]
|
||||
["default"]
|
||||
)
|
||||
os.environ["RR_ROOT"] = rr_path
|
||||
self._rr_path = rr_path
|
||||
|
||||
def _get_rr_bin_path(self, rr_root=None):
|
||||
# type: (str) -> str
|
||||
"""Get path to RR bin folder."""
|
||||
rr_root = rr_root or self._rr_path
|
||||
is_64bit_python = sys.maxsize > 2 ** 32
|
||||
|
||||
rr_bin_path = ""
|
||||
if sys.platform.lower() == "win32":
|
||||
rr_bin_path = "/bin/win64"
|
||||
if not is_64bit_python:
|
||||
# we are using 64bit python
|
||||
rr_bin_path = "/bin/win"
|
||||
rr_bin_path = rr_bin_path.replace(
|
||||
"/", os.path.sep
|
||||
)
|
||||
|
||||
if sys.platform.lower() == "darwin":
|
||||
rr_bin_path = "/bin/mac64"
|
||||
if not is_64bit_python:
|
||||
rr_bin_path = "/bin/mac"
|
||||
|
||||
if sys.platform.lower() == "linux":
|
||||
rr_bin_path = "/bin/lx64"
|
||||
|
||||
return os.path.join(rr_root, rr_bin_path)
|
||||
|
||||
def _initialize_module_path(self):
|
||||
# type: () -> None
|
||||
"""Set RR modules for Python."""
|
||||
# default for linux
|
||||
rr_bin = self._get_rr_bin_path()
|
||||
rr_module_path = os.path.join(rr_bin, "lx64/lib")
|
||||
|
||||
if sys.platform.lower() == "win32":
|
||||
rr_module_path = rr_bin
|
||||
rr_module_path = rr_module_path.replace(
|
||||
"/", os.path.sep
|
||||
)
|
||||
|
||||
if sys.platform.lower() == "darwin":
|
||||
rr_module_path = os.path.join(rr_bin, "lib/python/27")
|
||||
|
||||
sys.path.append(os.path.join(self._rr_path, rr_module_path))
|
||||
|
||||
def create_submission(self, jobs, submitter_attributes, file_name=None):
|
||||
# type: (list[RRJob], list[SubmitterParameter], str) -> SubmitFile
|
||||
"""Create jobs submission file.
|
||||
|
||||
Args:
|
||||
jobs (list): List of :class:`RRJob`
|
||||
submitter_attributes (list): List of submitter attributes
|
||||
:class:`SubmitterParameter` for whole submission batch.
|
||||
file_name (str), optional): File path to write data to.
|
||||
|
||||
Returns:
|
||||
str: XML data of job submission files.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_file(self, file, mode=RR_SUBMIT_CONSOLE):
|
||||
# type: (SubmitFile, int) -> None
|
||||
if mode == self.RR_SUBMIT_CONSOLE:
|
||||
self._submit_using_console(file)
|
||||
|
||||
# RR v7 supports only Python 2.7 so we bail out in fear
|
||||
# until there is support for Python 3 😰
|
||||
raise NotImplementedError(
|
||||
"Submission via RoyalRender API is not supported yet")
|
||||
# self._submit_using_api(file)
|
||||
|
||||
def _submit_using_console(self, file):
|
||||
# type: (SubmitFile) -> bool
|
||||
rr_console = os.path.join(
|
||||
self._get_rr_bin_path(),
|
||||
"rrSubmitterconsole"
|
||||
)
|
||||
|
||||
if sys.platform.lower() == "darwin":
|
||||
if "/bin/mac64" in rr_console:
|
||||
rr_console = rr_console.replace("/bin/mac64", "/bin/mac")
|
||||
|
||||
if sys.platform.lower() == "win32":
|
||||
if "/bin/win64" in rr_console:
|
||||
rr_console = rr_console.replace("/bin/win64", "/bin/win")
|
||||
rr_console += ".exe"
|
||||
|
||||
args = [rr_console, file]
|
||||
run_subprocess(" ".join(args), logger=log)
|
||||
|
||||
def _submit_using_api(self, file):
|
||||
# type: (SubmitFile) -> None
|
||||
"""Use RR API to submit jobs.
|
||||
|
||||
Args:
|
||||
file (SubmitFile): Submit jobs definition.
|
||||
|
||||
Throws:
|
||||
RoyalRenderException: When something fails.
|
||||
|
||||
"""
|
||||
self._initialize_module_path()
|
||||
import libpyRR2 as rrLib # noqa
|
||||
from rrJob import getClass_JobBasics # noqa
|
||||
import libpyRR2 as _RenderAppBasic # noqa
|
||||
|
||||
tcp = rrLib._rrTCP("") # noqa
|
||||
rr_server = tcp.getRRServer()
|
||||
|
||||
if len(rr_server) == 0:
|
||||
log.info("Got RR IP address {}".format(rr_server))
|
||||
|
||||
# TODO: Port is hardcoded in RR? If not, move it to Settings
|
||||
if not tcp.setServer(rr_server, 7773):
|
||||
log.error(
|
||||
"Can not set RR server: {}".format(tcp.errorMessage()))
|
||||
raise RoyalRenderException(tcp.errorMessage())
|
||||
|
||||
# TODO: This need UI and better handling of username/password.
|
||||
# We can't store password in keychain as it is pulled multiple
|
||||
# times and users on linux must enter keychain password every time.
|
||||
# Probably best way until we setup our own user management would be
|
||||
# to encrypt password and save it to json locally. Not bulletproof
|
||||
# but at least it is not stored in plaintext.
|
||||
reg = OpenPypeSettingsRegistry()
|
||||
try:
|
||||
rr_user = reg.get_item("rr_username")
|
||||
rr_password = reg.get_item("rr_password")
|
||||
except ValueError:
|
||||
# user has no rr credentials set
|
||||
pass
|
||||
else:
|
||||
# login to RR
|
||||
tcp.setLogin(rr_user, rr_password)
|
||||
|
||||
job = getClass_JobBasics()
|
||||
renderer = _RenderAppBasic()
|
||||
|
||||
# iterate over SubmitFile, set _JobBasic (job) and renderer
|
||||
# and feed it to jobSubmitNew()
|
||||
# not implemented yet
|
||||
job.renderer = renderer
|
||||
tcp.jobSubmitNew(job)
|
||||
|
||||
|
||||
class RoyalRenderException(Exception):
|
||||
"""Exception used in various error states coming from RR."""
|
||||
pass
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Collect default Deadline server."""
|
||||
import pyblish.api
|
||||
|
||||
|
||||
class CollectDefaultRRPath(pyblish.api.ContextPlugin):
|
||||
"""Collect default Royal Render path."""
|
||||
|
||||
order = pyblish.api.CollectorOrder + 0.01
|
||||
label = "Default Royal Render Path"
|
||||
|
||||
def process(self, context):
|
||||
try:
|
||||
rr_module = context.data.get(
|
||||
"openPypeModules")["royalrender"]
|
||||
except AttributeError:
|
||||
msg = "Cannot get OpenPype Royal Render module."
|
||||
self.log.error(msg)
|
||||
raise AssertionError(msg)
|
||||
|
||||
# get default deadline webservice url from deadline module
|
||||
self.log.debug(rr_module.rr_paths)
|
||||
context.data["defaultRRPath"] = rr_module.rr_paths["default"] # noqa: E501
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pyblish.api
|
||||
|
||||
|
||||
class CollectRRPathFromInstance(pyblish.api.InstancePlugin):
|
||||
"""Collect RR Path from instance."""
|
||||
|
||||
order = pyblish.api.CollectorOrder
|
||||
label = "Royal Render Path from the Instance"
|
||||
families = ["rendering"]
|
||||
|
||||
def process(self, instance):
|
||||
instance.data["rrPath"] = self._collect_rr_path(instance)
|
||||
self.log.info(
|
||||
"Using {} for submission.".format(instance.data["rrPath"]))
|
||||
|
||||
@staticmethod
|
||||
def _collect_rr_path(render_instance):
|
||||
# type: (pyblish.api.Instance) -> str
|
||||
"""Get Royal Render path from render instance."""
|
||||
rr_settings = (
|
||||
render_instance.context.data
|
||||
["system_settings"]
|
||||
["modules"]
|
||||
["royalrender"]
|
||||
)
|
||||
try:
|
||||
default_servers = rr_settings["rr_paths"]
|
||||
project_servers = (
|
||||
render_instance.context.data
|
||||
["project_settings"]
|
||||
["royalrender"]
|
||||
["rr_paths"]
|
||||
)
|
||||
rr_servers = {
|
||||
k: default_servers[k]
|
||||
for k in project_servers
|
||||
if k in default_servers
|
||||
}
|
||||
|
||||
except AttributeError:
|
||||
# Handle situation were we had only one url for deadline.
|
||||
return render_instance.context.data["defaultRRPath"]
|
||||
|
||||
return rr_servers[
|
||||
list(rr_servers.keys())[
|
||||
int(render_instance.data.get("rrPaths"))
|
||||
]
|
||||
]
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Collect sequences from Royal Render Job."""
|
||||
import os
|
||||
import re
|
||||
import copy
|
||||
import json
|
||||
from pprint import pformat
|
||||
|
||||
import pyblish.api
|
||||
from avalon import api
|
||||
|
||||
|
||||
def collect(root,
|
||||
regex=None,
|
||||
exclude_regex=None,
|
||||
frame_start=None,
|
||||
frame_end=None):
|
||||
"""Collect sequence collections in root"""
|
||||
|
||||
from avalon.vendor import clique
|
||||
|
||||
files = []
|
||||
for filename in os.listdir(root):
|
||||
|
||||
# Must have extension
|
||||
ext = os.path.splitext(filename)[1]
|
||||
if not ext:
|
||||
continue
|
||||
|
||||
# Only files
|
||||
if not os.path.isfile(os.path.join(root, filename)):
|
||||
continue
|
||||
|
||||
# Include and exclude regex
|
||||
if regex and not re.search(regex, filename):
|
||||
continue
|
||||
if exclude_regex and re.search(exclude_regex, filename):
|
||||
continue
|
||||
|
||||
files.append(filename)
|
||||
|
||||
# Match collections
|
||||
# Support filenames like: projectX_shot01_0010.tiff with this regex
|
||||
pattern = r"(?P<index>(?P<padding>0*)\d+)\.\D+\d?$"
|
||||
collections, remainder = clique.assemble(files,
|
||||
patterns=[pattern],
|
||||
minimum_items=1)
|
||||
|
||||
# Ignore any remainders
|
||||
if remainder:
|
||||
print("Skipping remainder {}".format(remainder))
|
||||
|
||||
# Exclude any frames outside start and end frame.
|
||||
for collection in collections:
|
||||
for index in list(collection.indexes):
|
||||
if frame_start is not None and index < frame_start:
|
||||
collection.indexes.discard(index)
|
||||
continue
|
||||
if frame_end is not None and index > frame_end:
|
||||
collection.indexes.discard(index)
|
||||
continue
|
||||
|
||||
# Keep only collections that have at least a single frame
|
||||
collections = [c for c in collections if c.indexes]
|
||||
|
||||
return collections
|
||||
|
||||
|
||||
class CollectSequencesFromJob(pyblish.api.ContextPlugin):
|
||||
"""Gather file sequences from job directory.
|
||||
|
||||
When "OPENPYPE_PUBLISH_DATA" environment variable is set these paths
|
||||
(folders or .json files) are parsed for image sequences. Otherwise the
|
||||
current working directory is searched for file sequences.
|
||||
|
||||
"""
|
||||
order = pyblish.api.CollectorOrder
|
||||
targets = ["rr_control"]
|
||||
label = "Collect Rendered Frames"
|
||||
|
||||
def process(self, context):
|
||||
if os.environ.get("OPENPYPE_PUBLISH_DATA"):
|
||||
self.log.debug(os.environ.get("OPENPYPE_PUBLISH_DATA"))
|
||||
paths = os.environ["OPENPYPE_PUBLISH_DATA"].split(os.pathsep)
|
||||
self.log.info("Collecting paths: {}".format(paths))
|
||||
else:
|
||||
cwd = context.get("workspaceDir", os.getcwd())
|
||||
paths = [cwd]
|
||||
|
||||
for path in paths:
|
||||
|
||||
self.log.info("Loading: {}".format(path))
|
||||
|
||||
if path.endswith(".json"):
|
||||
# Search using .json configuration
|
||||
with open(path, "r") as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except Exception as exc:
|
||||
self.log.error("Error loading json: "
|
||||
"{} - Exception: {}".format(path, exc))
|
||||
raise
|
||||
|
||||
cwd = os.path.dirname(path)
|
||||
root_override = data.get("root")
|
||||
if root_override:
|
||||
if os.path.isabs(root_override):
|
||||
root = root_override
|
||||
else:
|
||||
root = os.path.join(cwd, root_override)
|
||||
else:
|
||||
root = cwd
|
||||
|
||||
metadata = data.get("metadata")
|
||||
if metadata:
|
||||
session = metadata.get("session")
|
||||
if session:
|
||||
self.log.info("setting session using metadata")
|
||||
api.Session.update(session)
|
||||
os.environ.update(session)
|
||||
|
||||
else:
|
||||
# Search in directory
|
||||
data = {}
|
||||
root = path
|
||||
|
||||
self.log.info("Collecting: {}".format(root))
|
||||
regex = data.get("regex")
|
||||
if regex:
|
||||
self.log.info("Using regex: {}".format(regex))
|
||||
|
||||
collections = collect(root=root,
|
||||
regex=regex,
|
||||
exclude_regex=data.get("exclude_regex"),
|
||||
frame_start=data.get("frameStart"),
|
||||
frame_end=data.get("frameEnd"))
|
||||
|
||||
self.log.info("Found collections: {}".format(collections))
|
||||
|
||||
if data.get("subset") and len(collections) > 1:
|
||||
self.log.error("Forced subset can only work with a single "
|
||||
"found sequence")
|
||||
raise RuntimeError("Invalid sequence")
|
||||
|
||||
fps = data.get("fps", 25)
|
||||
|
||||
# Get family from the data
|
||||
families = data.get("families", ["render"])
|
||||
if "render" not in families:
|
||||
families.append("render")
|
||||
if "ftrack" not in families:
|
||||
families.append("ftrack")
|
||||
if "review" not in families:
|
||||
families.append("review")
|
||||
|
||||
for collection in collections:
|
||||
instance = context.create_instance(str(collection))
|
||||
self.log.info("Collection: %s" % list(collection))
|
||||
|
||||
# Ensure each instance gets a unique reference to the data
|
||||
data = copy.deepcopy(data)
|
||||
|
||||
# If no subset provided, get it from collection's head
|
||||
subset = data.get("subset", collection.head.rstrip("_. "))
|
||||
|
||||
# If no start or end frame provided, get it from collection
|
||||
indices = list(collection.indexes)
|
||||
start = data.get("frameStart", indices[0])
|
||||
end = data.get("frameEnd", indices[-1])
|
||||
|
||||
# root = os.path.normpath(root)
|
||||
# self.log.info("Source: {}}".format(data.get("source", "")))
|
||||
|
||||
ext = list(collection)[0].split('.')[-1]
|
||||
|
||||
instance.data.update({
|
||||
"name": str(collection),
|
||||
"family": families[0], # backwards compatibility / pyblish
|
||||
"families": list(families),
|
||||
"subset": subset,
|
||||
"asset": data.get("asset", api.Session["AVALON_ASSET"]),
|
||||
"stagingDir": root,
|
||||
"frameStart": start,
|
||||
"frameEnd": end,
|
||||
"fps": fps,
|
||||
"source": data.get('source', '')
|
||||
})
|
||||
instance.append(collection)
|
||||
instance.context.data['fps'] = fps
|
||||
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
||||
representation = {
|
||||
'name': ext,
|
||||
'ext': '{}'.format(ext),
|
||||
'files': list(collection),
|
||||
"stagingDir": root,
|
||||
"anatomy_template": "render",
|
||||
"fps": fps,
|
||||
"tags": ['review']
|
||||
}
|
||||
instance.data["representations"].append(representation)
|
||||
|
||||
if data.get('user'):
|
||||
context.data["user"] = data['user']
|
||||
|
||||
self.log.debug("Collected instance:\n"
|
||||
"{}".format(pformat(instance.data)))
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Module providing support for Royal Render."""
|
||||
import os
|
||||
import openpype.modules
|
||||
from openpype.modules import OpenPypeModule
|
||||
from openpype_interfaces import IPluginPaths
|
||||
|
||||
|
||||
class RoyalRenderModule(OpenPypeModule, IPluginPaths):
|
||||
"""Class providing basic Royal Render implementation logic."""
|
||||
name = "royalrender"
|
||||
|
||||
@property
|
||||
def api(self):
|
||||
if not self._api:
|
||||
# import royal render modules
|
||||
from . import api as rr_api
|
||||
self._api = rr_api.Api(self.settings)
|
||||
|
||||
return self._api
|
||||
|
||||
def __init__(self, manager, settings):
|
||||
# type: (openpype.modules.base.ModulesManager, dict) -> None
|
||||
self.rr_paths = {}
|
||||
self._api = None
|
||||
self.settings = settings
|
||||
super(RoyalRenderModule, self).__init__(manager, settings)
|
||||
|
||||
def initialize(self, module_settings):
|
||||
# type: (dict) -> None
|
||||
rr_settings = module_settings[self.name]
|
||||
self.enabled = rr_settings["enabled"]
|
||||
self.rr_paths = rr_settings.get("rr_paths")
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_paths():
|
||||
# type: () -> dict
|
||||
"""Royal Render plugin paths.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of plugin paths for RR.
|
||||
"""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return {
|
||||
"publish": [os.path.join(current_dir, "plugins", "publish")]
|
||||
}
|
||||
256
openpype/modules/default_modules/royal_render/rr_job.py
Normal file
256
openpype/modules/default_modules/royal_render/rr_job.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Python wrapper for RoyalRender XML job file."""
|
||||
from xml.dom import minidom as md
|
||||
import attr
|
||||
from collections import namedtuple, OrderedDict
|
||||
|
||||
|
||||
CustomAttribute = namedtuple("CustomAttribute", ["name", "value"])
|
||||
|
||||
|
||||
@attr.s
|
||||
class RRJob:
|
||||
"""Mapping of Royal Render job file to a data class."""
|
||||
|
||||
# Required
|
||||
# --------
|
||||
|
||||
# Name of your render application. Same as in the render config file.
|
||||
# (Maya, Softimage)
|
||||
Software = attr.ib() # type: str
|
||||
|
||||
# The OS the scene was created on, all texture paths are set on
|
||||
# that OS. Possible values are windows, linux, osx
|
||||
SceneOS = attr.ib() # type: str
|
||||
|
||||
# Renderer you use. Same as in the render config file
|
||||
# (VRay, Mental Ray, Arnold)
|
||||
Renderer = attr.ib() # type: str
|
||||
|
||||
# Version you want to render with. (5.11, 2010, 12)
|
||||
Version = attr.ib() # type: str
|
||||
|
||||
# Name of the scene file with full path.
|
||||
SceneName = attr.ib() # type: str
|
||||
|
||||
# Is the job enabled for submission?
|
||||
# enabled by default
|
||||
IsActive = attr.ib() # type: str
|
||||
|
||||
# Sequence settings of this job
|
||||
SeqStart = attr.ib() # type: int
|
||||
SeqEnd = attr.ib() # type: int
|
||||
SeqStep = attr.ib() # type: int
|
||||
SeqFileOffset = attr.ib() # type: int
|
||||
|
||||
# If you specify ImageDir, then ImageFilename has no path. If you do
|
||||
# NOT specify ImageDir, then ImageFilename has to include the path.
|
||||
# Same for ImageExtension.
|
||||
# Important: Do not forget any _ or . in front or after the frame
|
||||
# numbering. Usually ImageExtension always starts with a . (.tga, .exr)
|
||||
ImageDir = attr.ib() # type: str
|
||||
ImageFilename = attr.ib() # type: str
|
||||
ImageExtension = attr.ib() # type: str
|
||||
|
||||
# Some applications always add a . or _ in front of the frame number.
|
||||
# Set this variable to that character. The user can then change
|
||||
# the filename at the rrSubmitter and the submitter keeps
|
||||
# track of this character.
|
||||
ImagePreNumberLetter = attr.ib() # type: str
|
||||
|
||||
# If you render a single file, e.g. Quicktime or Avi, then you have to
|
||||
# set this value. Videos have to be rendered at once on one client.
|
||||
ImageSingleOutputFile = attr.ib(default="false") # type: str
|
||||
|
||||
# Semi-Required (required for some render applications)
|
||||
# -----------------------------------------------------
|
||||
|
||||
# The database of your scene file. In Maya and XSI called "project",
|
||||
# in Lightwave "content dir"
|
||||
SceneDatabaseDir = attr.ib(default=None) # type: str
|
||||
|
||||
# Required if you want to split frames on multiple clients
|
||||
ImageWidth = attr.ib(default=None) # type: int
|
||||
ImageHeight = attr.ib(default=None) # type: int
|
||||
Camera = attr.ib(default=None) # type: str
|
||||
Layer = attr.ib(default=None) # type: str
|
||||
Channel = attr.ib(default=None) # type: str
|
||||
|
||||
# Optional
|
||||
# --------
|
||||
|
||||
# Used for the RR render license function.
|
||||
# E.g. If you render with mentalRay, then add mentalRay. If you render
|
||||
# with Nuke and you use Furnace plugins in your comp, add Furnace.
|
||||
# TODO: determine how this work for multiple plugins
|
||||
RequiredPlugins = attr.ib(default=None) # type: str
|
||||
|
||||
# Frame Padding of the frame number in the rendered filename.
|
||||
# Some render config files are setting the padding at render time.
|
||||
ImageFramePadding = attr.ib(default=None) # type: str
|
||||
|
||||
# Some render applications support overriding the image format at
|
||||
# the render commandline.
|
||||
OverrideImageFormat = attr.ib(default=None) # type: str
|
||||
|
||||
# rrControl can display the name of additonal channels that are
|
||||
# rendered. Each channel requires these two values. ChannelFilename
|
||||
# contains the full path.
|
||||
ChannelFilename = attr.ib(default=None) # type: str
|
||||
ChannelExtension = attr.ib(default=None) # type: str
|
||||
|
||||
# A value between 0 and 255. Each job gets the Pre ID attached as small
|
||||
# letter to the main ID. A new main ID is generated for every machine
|
||||
# for every 5/1000s.
|
||||
PreID = attr.ib(default=None) # type: int
|
||||
|
||||
# When the job is received by the server, the server checks for other
|
||||
# jobs send from this machine. If a job with the PreID was found, then
|
||||
# this jobs waits for the other job. Note: This flag can be used multiple
|
||||
# times to wait for multiple jobs.
|
||||
WaitForPreID = attr.ib(default=None) # type: int
|
||||
|
||||
# List of submitter options per job
|
||||
# list item must be of `SubmitterParameter` type
|
||||
SubmitterParameters = attr.ib(factory=list) # type: list
|
||||
|
||||
# List of Custom job attributes
|
||||
# Royal Render support custom attributes in format <CustomFoo> or
|
||||
# <CustomSomeOtherAttr>
|
||||
# list item must be of `CustomAttribute` named tuple
|
||||
CustomAttributes = attr.ib(factory=list) # type: list
|
||||
|
||||
# Additional information for subsequent publish script and
|
||||
# for better display in rrControl
|
||||
UserName = attr.ib(default=None) # type: str
|
||||
CustomSeQName = attr.ib(default=None) # type: str
|
||||
CustomSHotName = attr.ib(default=None) # type: str
|
||||
CustomVersionName = attr.ib(default=None) # type: str
|
||||
CustomUserInfo = attr.ib(default=None) # type: str
|
||||
SubmitMachine = attr.ib(default=None) # type: str
|
||||
Color_ID = attr.ib(default=2) # type: int
|
||||
|
||||
RequiredLicenses = attr.ib(default=None) # type: str
|
||||
|
||||
# Additional frame info
|
||||
Priority = attr.ib(default=50) # type: int
|
||||
TotalFrames = attr.ib(default=None) # type: int
|
||||
Tiled = attr.ib(default=None) # type: str
|
||||
|
||||
|
||||
class SubmitterParameter:
|
||||
"""Wrapper for Submitter Parameters."""
|
||||
def __init__(self, parameter, *args):
|
||||
# type: (str, list) -> None
|
||||
self._parameter = parameter
|
||||
self._values = args
|
||||
|
||||
def serialize(self):
|
||||
# type: () -> str
|
||||
"""Serialize submitter parameter as a string value.
|
||||
|
||||
This can be later on used as text node in job xml file.
|
||||
|
||||
Returns:
|
||||
str: concatenated string of parameter values.
|
||||
|
||||
"""
|
||||
return '"{param}={val}"'.format(
|
||||
param=self._parameter, val="~".join(self._values))
|
||||
|
||||
|
||||
@attr.s
|
||||
class SubmitFile:
|
||||
"""Class wrapping Royal Render submission XML file."""
|
||||
|
||||
# Syntax version of the submission file.
|
||||
syntax_version = attr.ib(default="6.0") # type: str
|
||||
|
||||
# Delete submission file after processing
|
||||
DeleteXML = attr.ib(default=1) # type: int
|
||||
|
||||
# List of submitter options per job
|
||||
# list item must be of `SubmitterParameter` type
|
||||
SubmitterParameters = attr.ib(factory=list) # type: list
|
||||
|
||||
# List of job is submission batch.
|
||||
# list item must be of type `RRJob`
|
||||
Jobs = attr.ib(factory=list) # type: list
|
||||
|
||||
@staticmethod
|
||||
def _process_submitter_parameters(parameters, dom, append_to):
|
||||
# type: (list[SubmitterParameter], md.Document, md.Element) -> None
|
||||
"""Take list of :class:`SubmitterParameter` and process it as XML.
|
||||
|
||||
This will take :class:`SubmitterParameter`, create XML element
|
||||
for them and convert value to Royal Render compatible string
|
||||
(options and values separated by ~)
|
||||
|
||||
Args:
|
||||
parameters (list of SubmitterParameter): List of parameters.
|
||||
dom (xml.dom.minidom.Document): XML Document
|
||||
append_to (xml.dom.minidom.Element): Element to append to.
|
||||
|
||||
"""
|
||||
for param in parameters:
|
||||
if not isinstance(param, SubmitterParameter):
|
||||
raise AttributeError(
|
||||
"{} is not of type `SubmitterParameter`".format(param))
|
||||
xml_parameter = dom.createElement("SubmitterParameter")
|
||||
xml_parameter.appendChild(dom.createTextNode(param.serialize()))
|
||||
append_to.appendChild(xml_parameter)
|
||||
|
||||
def serialize(self):
|
||||
# type: () -> str
|
||||
"""Return all data serialized as XML.
|
||||
|
||||
Returns:
|
||||
str: XML data as string.
|
||||
|
||||
"""
|
||||
def filter_data(a, v):
|
||||
"""Skip private attributes."""
|
||||
if a.name.startswith("_"):
|
||||
return False
|
||||
if v is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
root = md.Document()
|
||||
# root element: <RR_Job_File syntax_version="6.0">
|
||||
job_file = root.createElement('RR_Job_File')
|
||||
job_file.setAttribute("syntax_version", self.syntax_version)
|
||||
|
||||
# handle Submitter Parameters for batch
|
||||
# <SubmitterParameter>foo=bar~baz~goo</SubmitterParameter>
|
||||
self._process_submitter_parameters(
|
||||
self.SubmitterParameters, root, job_file)
|
||||
|
||||
for job in self.Jobs: # type: RRJob
|
||||
if not isinstance(job, RRJob):
|
||||
raise AttributeError(
|
||||
"{} is not of type `SubmitterParameter`".format(job))
|
||||
xml_job = root.createElement("Job")
|
||||
# handle Submitter Parameters for job
|
||||
self._process_submitter_parameters(
|
||||
job.SubmitterParameters, root, xml_job
|
||||
)
|
||||
job_custom_attributes = job.CustomAttributes
|
||||
|
||||
serialized_job = attr.asdict(
|
||||
job, dict_factory=OrderedDict, filter=filter_data)
|
||||
serialized_job.pop("CustomAttributes")
|
||||
serialized_job.pop("SubmitterParameters")
|
||||
|
||||
for custom_attr in job_custom_attributes: # type: CustomAttribute
|
||||
serialized_job["Custom{}".format(
|
||||
custom_attr.name)] = custom_attr.value
|
||||
|
||||
for item, value in serialized_job.items():
|
||||
xml_attr = root.create(item)
|
||||
xml_attr.appendChild(
|
||||
root.createTextNode(value)
|
||||
)
|
||||
xml_job.appendChild(xml_attr)
|
||||
|
||||
return root.toprettyxml(indent="\t")
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
## OpenPype RoyalRender integration plugins
|
||||
|
||||
### Installation
|
||||
|
||||
Copy content of this folder to your `RR_ROOT` (place where RoyalRender studio wide installation is).
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""This is RR control plugin that runs on the job by user interaction.
|
||||
|
||||
It asks user for context to publish, getting it from OpenPype. In order to
|
||||
run it needs `OPENPYPE_ROOT` to be set to know where to execute OpenPype.
|
||||
|
||||
"""
|
||||
import rr # noqa
|
||||
import rrGlobal # noqa
|
||||
import subprocess
|
||||
import os
|
||||
import glob
|
||||
import platform
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
|
||||
class OpenPypeContextSelector:
|
||||
"""Class to handle publishing context determination in RR."""
|
||||
|
||||
def __init__(self):
|
||||
self.job = rr.getJob()
|
||||
self.context = None
|
||||
|
||||
self.openpype_executable = "openpype_gui"
|
||||
if platform.system().lower() == "windows":
|
||||
self.openpype_executable = "{}.exe".format(
|
||||
self.openpype_executable)
|
||||
|
||||
op_path = os.environ.get("OPENPYPE_ROOT")
|
||||
print("initializing ... {}".format(op_path))
|
||||
if not op_path:
|
||||
print("Warning: OpenPype root is not found.")
|
||||
|
||||
if platform.system().lower() == "windows":
|
||||
print(" * trying to find OpenPype on local computer.")
|
||||
op_path = os.path.join(
|
||||
os.environ.get("PROGRAMFILES"),
|
||||
"OpenPype", "openpype_console.exe"
|
||||
)
|
||||
if os.path.exists(op_path):
|
||||
print(" - found OpenPype installation {}".format(op_path))
|
||||
else:
|
||||
# try to find in user local context
|
||||
op_path = os.path.join(
|
||||
os.environ.get("LOCALAPPDATA"),
|
||||
"Programs",
|
||||
"OpenPype", "openpype_console.exe"
|
||||
)
|
||||
if os.path.exists(op_path):
|
||||
print(
|
||||
" - found OpenPype installation {}".format(
|
||||
op_path))
|
||||
else:
|
||||
raise Exception("Error: OpenPype was not found.")
|
||||
|
||||
self.openpype_root = op_path
|
||||
|
||||
# TODO: this should try to find metadata file. Either using
|
||||
# jobs custom attributes or using environment variable
|
||||
# or just using plain existence of file.
|
||||
# self.context = self._process_metadata_file()
|
||||
|
||||
def _process_metadata_file(self):
|
||||
"""Find and process metadata file.
|
||||
|
||||
Try to find metadata json file in job folder to get context from.
|
||||
|
||||
Returns:
|
||||
dict: Context from metadata json file.
|
||||
|
||||
"""
|
||||
image_dir = self.job.imageDir
|
||||
metadata_files = glob.glob(
|
||||
"{}{}*_metadata.json".format(image_dir, os.path.sep))
|
||||
if not metadata_files:
|
||||
return {}
|
||||
|
||||
raise NotImplementedError(
|
||||
"Processing existing metadata not implemented yet.")
|
||||
|
||||
def process_job(self):
|
||||
"""Process selected job.
|
||||
|
||||
This should process selected job. If context can be determined
|
||||
automatically, no UI will be show and publishing will directly
|
||||
proceed.
|
||||
"""
|
||||
if not self.context:
|
||||
self.show()
|
||||
|
||||
self.context["user"] = self.job.userName
|
||||
self.run_publish()
|
||||
|
||||
def show(self):
|
||||
"""Show UI for context selection.
|
||||
|
||||
Because of RR UI limitations, this must be done using OpenPype
|
||||
itself.
|
||||
|
||||
"""
|
||||
tf = tempfile.TemporaryFile(delete=False)
|
||||
context_file = tf.name
|
||||
op_args = [os.path.join(self.openpype_root, self.openpype_executable),
|
||||
"contextselection", tf.name]
|
||||
|
||||
tf.close()
|
||||
print(">>> running {}".format(" ".join(op_args)))
|
||||
|
||||
subprocess.call(op_args)
|
||||
|
||||
with open(context_file, "r") as cf:
|
||||
self.context = json.load(cf)
|
||||
|
||||
os.unlink(context_file)
|
||||
print(">>> context: {}".format(self.context))
|
||||
|
||||
if not self.context or \
|
||||
not self.context.get("project") or \
|
||||
not self.context.get("asset") or \
|
||||
not self.context.get("task"):
|
||||
self._show_rr_warning("Context selection failed.")
|
||||
return
|
||||
|
||||
# self.context["app_name"] = self.job.renderer.name
|
||||
self.context["app_name"] = "maya/2020"
|
||||
|
||||
@staticmethod
|
||||
def _show_rr_warning(text):
|
||||
warning_dialog = rrGlobal.getGenericUI()
|
||||
warning_dialog.addItem(rrGlobal.genUIType.label, "infoLabel", "")
|
||||
warning_dialog.setText("infoLabel", text)
|
||||
warning_dialog.addItem(
|
||||
rrGlobal.genUIType.layoutH, "btnLayout", "")
|
||||
warning_dialog.addItem(
|
||||
rrGlobal.genUIType.closeButton, "Ok", "btnLayout")
|
||||
warning_dialog.execute()
|
||||
del warning_dialog
|
||||
|
||||
def run_publish(self):
|
||||
"""Run publish process."""
|
||||
env = {'AVALON_PROJECT': str(self.context.get("project")),
|
||||
"AVALON_ASSET": str(self.context.get("asset")),
|
||||
"AVALON_TASK": str(self.context.get("task")),
|
||||
"AVALON_APP_NAME": str(self.context.get("app_name"))}
|
||||
|
||||
print(">>> setting environment:")
|
||||
for k, v in env.items():
|
||||
print(" {}: {}".format(k, v))
|
||||
|
||||
args = [os.path.join(self.openpype_root, self.openpype_executable),
|
||||
'publish', '-t', "rr_control", "--gui",
|
||||
os.path.join(self.job.imageDir,
|
||||
os.path.dirname(self.job.imageFileName))
|
||||
]
|
||||
|
||||
print(">>> running {}".format(" ".join(args)))
|
||||
orig = os.environ.copy()
|
||||
orig.update(env)
|
||||
try:
|
||||
subprocess.call(args, env=orig)
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._show_rr_warning(" Publish failed [ {} ]".format(
|
||||
e.returncode
|
||||
))
|
||||
|
||||
|
||||
print("running selector")
|
||||
selector = OpenPypeContextSelector()
|
||||
selector.process_job()
|
||||
|
|
@ -97,7 +97,7 @@ class DropboxHandler(AbstractProvider):
|
|||
},
|
||||
# roots could be overriden only on Project level, User cannot
|
||||
{
|
||||
"key": "roots",
|
||||
"key": "root",
|
||||
"label": "Roots",
|
||||
"type": "dict-roots",
|
||||
"object_type": {
|
||||
|
|
@ -389,7 +389,7 @@ class DropboxHandler(AbstractProvider):
|
|||
{"root": {"root_ONE": "value", "root_TWO":"value}}
|
||||
Format is importing for usage of python's format ** approach
|
||||
"""
|
||||
return self.presets['roots']
|
||||
return self.presets['root']
|
||||
|
||||
def resolve_path(self, path, root_config=None, anatomy=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ class GDriveHandler(AbstractProvider):
|
|||
},
|
||||
# roots could be overriden only on Project leve, User cannot
|
||||
{
|
||||
"key": "roots",
|
||||
"key": "root",
|
||||
"label": "Roots",
|
||||
"type": "dict-roots",
|
||||
"object_type": {
|
||||
|
|
@ -174,7 +174,7 @@ class GDriveHandler(AbstractProvider):
|
|||
Format is importing for usage of python's format ** approach
|
||||
"""
|
||||
# GDrive roots cannot be locally overridden
|
||||
return self.presets['roots']
|
||||
return self.presets['root']
|
||||
|
||||
def get_tree(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class LocalDriveHandler(AbstractProvider):
|
|||
# for non 'studio' sites, 'studio' is configured in Anatomy
|
||||
editable = [
|
||||
{
|
||||
"key": "roots",
|
||||
"key": "root",
|
||||
"label": "Roots",
|
||||
"type": "dict-roots",
|
||||
"object_type": {
|
||||
|
|
@ -73,7 +73,7 @@ class LocalDriveHandler(AbstractProvider):
|
|||
"""
|
||||
editable = [
|
||||
{
|
||||
'key': "roots",
|
||||
'key': "root",
|
||||
'label': "Roots",
|
||||
'type': 'dict'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ class SFTPHandler(AbstractProvider):
|
|||
},
|
||||
# roots could be overriden only on Project leve, User cannot
|
||||
{
|
||||
"key": "roots",
|
||||
"key": "root",
|
||||
"label": "Roots",
|
||||
"type": "dict-roots",
|
||||
"object_type": {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class PypeCommands:
|
|||
standalonepublish.main()
|
||||
|
||||
@staticmethod
|
||||
def publish(paths, targets=None):
|
||||
def publish(paths, targets=None, gui=False):
|
||||
"""Start headless publishing.
|
||||
|
||||
Publish use json from passed paths argument.
|
||||
|
|
@ -88,20 +88,35 @@ class PypeCommands:
|
|||
paths (list): Paths to jsons.
|
||||
targets (string): What module should be targeted
|
||||
(to choose validator for example)
|
||||
gui (bool): Show publish UI.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When there is no path to process.
|
||||
"""
|
||||
if not any(paths):
|
||||
raise RuntimeError("No publish paths specified")
|
||||
|
||||
from openpype.modules import ModulesManager
|
||||
from openpype import install, uninstall
|
||||
from openpype.api import Logger
|
||||
from openpype.tools.utils.host_tools import show_publish
|
||||
from openpype.tools.utils.lib import qt_app_context
|
||||
|
||||
# Register target and host
|
||||
import pyblish.api
|
||||
import pyblish.util
|
||||
|
||||
log = Logger.get_logger()
|
||||
|
||||
install()
|
||||
|
||||
manager = ModulesManager()
|
||||
|
||||
publish_paths = manager.collect_plugin_paths()["publish"]
|
||||
|
||||
for path in publish_paths:
|
||||
pyblish.api.register_plugin_path(path)
|
||||
|
||||
if not any(paths):
|
||||
raise RuntimeError("No publish paths specified")
|
||||
|
||||
env = get_app_environments_for_context(
|
||||
os.environ["AVALON_PROJECT"],
|
||||
os.environ["AVALON_ASSET"],
|
||||
|
|
@ -110,32 +125,39 @@ class PypeCommands:
|
|||
)
|
||||
os.environ.update(env)
|
||||
|
||||
log = Logger.get_logger()
|
||||
|
||||
install()
|
||||
|
||||
pyblish.api.register_target("filesequence")
|
||||
pyblish.api.register_host("shell")
|
||||
|
||||
if targets:
|
||||
for target in targets:
|
||||
print(f"setting target: {target}")
|
||||
pyblish.api.register_target(target)
|
||||
else:
|
||||
pyblish.api.register_target("filesequence")
|
||||
|
||||
os.environ["OPENPYPE_PUBLISH_DATA"] = os.pathsep.join(paths)
|
||||
|
||||
log.info("Running publish ...")
|
||||
|
||||
# Error exit as soon as any error occurs.
|
||||
error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
|
||||
plugins = pyblish.api.discover()
|
||||
print("Using plugins:")
|
||||
for plugin in plugins:
|
||||
print(plugin)
|
||||
|
||||
for result in pyblish.util.publish_iter():
|
||||
if result["error"]:
|
||||
log.error(error_format.format(**result))
|
||||
uninstall()
|
||||
sys.exit(1)
|
||||
if gui:
|
||||
with qt_app_context():
|
||||
show_publish()
|
||||
else:
|
||||
# Error exit as soon as any error occurs.
|
||||
error_format = ("Failed {plugin.__name__}: "
|
||||
"{error} -- {error.traceback}")
|
||||
|
||||
for result in pyblish.util.publish_iter():
|
||||
if result["error"]:
|
||||
log.error(error_format.format(**result))
|
||||
# uninstall()
|
||||
sys.exit(1)
|
||||
|
||||
log.info("Publish finished.")
|
||||
uninstall()
|
||||
|
||||
@staticmethod
|
||||
def remotepublishfromapp(project, batch_dir, host, user, targets=None):
|
||||
|
|
|
|||
|
|
@ -167,6 +167,16 @@
|
|||
"ffmpeg": 48
|
||||
}
|
||||
},
|
||||
"royalrender": {
|
||||
"enabled": false,
|
||||
"rr_paths": {
|
||||
"default": {
|
||||
"windows": "",
|
||||
"darwin": "",
|
||||
"linux": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"log_viewer": {
|
||||
"enabled": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -180,6 +180,31 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dict",
|
||||
"key": "royalrender",
|
||||
"label": "Royal Render",
|
||||
"require_restart": true,
|
||||
"collapsible": true,
|
||||
"checkbox_key": "enabled",
|
||||
"children": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"key": "enabled",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"type": "dict-modifiable",
|
||||
"object_type": {
|
||||
"type": "path",
|
||||
"multiplatform": true
|
||||
},
|
||||
"key": "rr_paths",
|
||||
"required_keys": ["default"],
|
||||
"label": "Royal Render Root Paths"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dict",
|
||||
"key": "log_viewer",
|
||||
|
|
|
|||
|
|
@ -765,6 +765,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
|||
border: 1px solid {color:border};
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
/* Subset Manager */
|
||||
#SubsetManagerDetailsText {}
|
||||
#SubsetManagerDetailsText[state="invalid"] {
|
||||
border: 1px solid #ff0000;
|
||||
}
|
||||
|
||||
/* Python console interpreter */
|
||||
#PythonInterpreterOutput, #PythonCodeEditor {
|
||||
|
|
|
|||
19
openpype/tools/subsetmanager/README.md
Normal file
19
openpype/tools/subsetmanager/README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Subset manager
|
||||
--------------
|
||||
|
||||
Simple UI showing list of created subset that will be published via Pyblish.
|
||||
Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are
|
||||
storing metadata about instance hidden from user.
|
||||
|
||||
This UI allows listing all created subset and removal of them if needed (
|
||||
in case use doesn't want to publish anymore, its using workfile as a starting
|
||||
file for different task and instances should be completely different etc.
|
||||
)
|
||||
|
||||
Host is expected to implemented:
|
||||
- `list_instances` - returning list of dictionaries (instances), must contain
|
||||
unique uuid field
|
||||
example:
|
||||
```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"pyblish.avalon.instance","asset":"Town"}]```
|
||||
- `remove_instance(instance)` - removes instance from file's metadata
|
||||
instance is a dictionary, with uuid field
|
||||
9
openpype/tools/subsetmanager/__init__.py
Normal file
9
openpype/tools/subsetmanager/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .window import (
|
||||
show,
|
||||
SubsetManagerWindow
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"show",
|
||||
"SubsetManagerWindow"
|
||||
)
|
||||
52
openpype/tools/subsetmanager/model.py
Normal file
52
openpype/tools/subsetmanager/model.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import uuid
|
||||
|
||||
from Qt import QtCore, QtGui
|
||||
|
||||
from avalon import api
|
||||
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
|
||||
|
||||
|
||||
class InstanceModel(QtGui.QStandardItemModel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InstanceModel, self).__init__(*args, **kwargs)
|
||||
self._instances_by_item_id = {}
|
||||
|
||||
def get_instance_by_id(self, item_id):
|
||||
return self._instances_by_item_id.get(item_id)
|
||||
|
||||
def refresh(self):
|
||||
self.clear()
|
||||
|
||||
self._instances_by_item_id = {}
|
||||
|
||||
instances = None
|
||||
host = api.registered_host()
|
||||
list_instances = getattr(host, "list_instances", None)
|
||||
if list_instances:
|
||||
instances = list_instances()
|
||||
|
||||
if not instances:
|
||||
return
|
||||
|
||||
items = []
|
||||
for instance_data in instances:
|
||||
item_id = str(uuid.uuid4())
|
||||
label = instance_data.get("label") or instance_data["subset"]
|
||||
item = QtGui.QStandardItem(label)
|
||||
item.setEnabled(True)
|
||||
item.setEditable(False)
|
||||
item.setData(item_id, ITEM_ID_ROLE)
|
||||
items.append(item)
|
||||
self._instances_by_item_id[item_id] = instance_data
|
||||
|
||||
if items:
|
||||
self.invisibleRootItem().appendRows(items)
|
||||
|
||||
def headerData(self, section, orientation, role):
|
||||
if role == QtCore.Qt.DisplayRole and section == 0:
|
||||
return "Instance"
|
||||
|
||||
return super(InstanceModel, self).headerData(
|
||||
section, orientation, role
|
||||
)
|
||||
110
openpype/tools/subsetmanager/widgets.py
Normal file
110
openpype/tools/subsetmanager/widgets.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import json
|
||||
from Qt import QtWidgets, QtCore
|
||||
|
||||
|
||||
class InstanceDetail(QtWidgets.QWidget):
|
||||
save_triggered = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(InstanceDetail, self).__init__(parent)
|
||||
|
||||
details_widget = QtWidgets.QPlainTextEdit(self)
|
||||
details_widget.setObjectName("SubsetManagerDetailsText")
|
||||
|
||||
save_btn = QtWidgets.QPushButton("Save", self)
|
||||
|
||||
self._block_changes = False
|
||||
self._editable = False
|
||||
self._item_id = None
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(details_widget, 1)
|
||||
layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight)
|
||||
|
||||
save_btn.clicked.connect(self._on_save_clicked)
|
||||
details_widget.textChanged.connect(self._on_text_change)
|
||||
|
||||
self._details_widget = details_widget
|
||||
self._save_btn = save_btn
|
||||
|
||||
self.set_editable(False)
|
||||
|
||||
def _on_save_clicked(self):
|
||||
if self.is_valid():
|
||||
self.save_triggered.emit()
|
||||
|
||||
def set_editable(self, enabled=True):
|
||||
self._editable = enabled
|
||||
self.update_state()
|
||||
|
||||
def update_state(self, valid=None):
|
||||
editable = self._editable
|
||||
if not self._item_id:
|
||||
editable = False
|
||||
|
||||
self._save_btn.setVisible(editable)
|
||||
self._details_widget.setReadOnly(not editable)
|
||||
if valid is None:
|
||||
valid = self.is_valid()
|
||||
|
||||
self._save_btn.setEnabled(valid)
|
||||
self._set_invalid_detail(valid)
|
||||
|
||||
def _set_invalid_detail(self, valid):
|
||||
state = ""
|
||||
if not valid:
|
||||
state = "invalid"
|
||||
|
||||
current_state = self._details_widget.property("state")
|
||||
if current_state != state:
|
||||
self._details_widget.setProperty("state", state)
|
||||
self._details_widget.style().polish(self._details_widget)
|
||||
|
||||
def set_details(self, container, item_id):
|
||||
self._item_id = item_id
|
||||
|
||||
text = "Nothing selected"
|
||||
if item_id:
|
||||
try:
|
||||
text = json.dumps(container, indent=4)
|
||||
except Exception:
|
||||
text = str(container)
|
||||
|
||||
self._block_changes = True
|
||||
self._details_widget.setPlainText(text)
|
||||
self._block_changes = False
|
||||
|
||||
self.update_state()
|
||||
|
||||
def instance_data_from_text(self):
|
||||
try:
|
||||
jsoned = json.loads(self._details_widget.toPlainText())
|
||||
except Exception:
|
||||
jsoned = None
|
||||
return jsoned
|
||||
|
||||
def item_id(self):
|
||||
return self._item_id
|
||||
|
||||
def is_valid(self):
|
||||
if not self._item_id:
|
||||
return True
|
||||
|
||||
value = self._details_widget.toPlainText()
|
||||
valid = False
|
||||
try:
|
||||
jsoned = json.loads(value)
|
||||
if jsoned and isinstance(jsoned, dict):
|
||||
valid = True
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return valid
|
||||
|
||||
def _on_text_change(self):
|
||||
if self._block_changes or not self._item_id:
|
||||
return
|
||||
|
||||
valid = self.is_valid()
|
||||
self.update_state(valid)
|
||||
218
openpype/tools/subsetmanager/window.py
Normal file
218
openpype/tools/subsetmanager/window.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
from Qt import QtWidgets, QtCore
|
||||
|
||||
from avalon import api
|
||||
from avalon.vendor import qtawesome
|
||||
|
||||
from openpype import style
|
||||
from openpype.tools.utils.lib import (
|
||||
iter_model_rows,
|
||||
qt_app_context
|
||||
)
|
||||
from openpype.tools.utils.models import RecursiveSortFilterProxyModel
|
||||
from .model import (
|
||||
InstanceModel,
|
||||
ITEM_ID_ROLE
|
||||
)
|
||||
from .widgets import InstanceDetail
|
||||
|
||||
|
||||
module = sys.modules[__name__]
|
||||
module.window = None
|
||||
|
||||
|
||||
class SubsetManagerWindow(QtWidgets.QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super(SubsetManagerWindow, self).__init__(parent=parent)
|
||||
self.setWindowTitle("Subset Manager 0.1")
|
||||
self.setObjectName("SubsetManager")
|
||||
if not parent:
|
||||
self.setWindowFlags(
|
||||
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
|
||||
)
|
||||
|
||||
self.resize(780, 430)
|
||||
|
||||
# Trigger refresh on first called show
|
||||
self._first_show = True
|
||||
|
||||
left_side_widget = QtWidgets.QWidget(self)
|
||||
|
||||
# Header part
|
||||
header_widget = QtWidgets.QWidget(left_side_widget)
|
||||
|
||||
# Filter input
|
||||
filter_input = QtWidgets.QLineEdit(header_widget)
|
||||
filter_input.setPlaceholderText("Filter subsets..")
|
||||
|
||||
# Refresh button
|
||||
icon = qtawesome.icon("fa.refresh", color="white")
|
||||
refresh_btn = QtWidgets.QPushButton(header_widget)
|
||||
refresh_btn.setIcon(icon)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(filter_input)
|
||||
header_layout.addWidget(refresh_btn)
|
||||
|
||||
# Instances view
|
||||
view = QtWidgets.QTreeView(left_side_widget)
|
||||
view.setIndentation(0)
|
||||
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
model = InstanceModel(view)
|
||||
proxy = RecursiveSortFilterProxyModel()
|
||||
proxy.setSourceModel(model)
|
||||
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
view.setModel(proxy)
|
||||
|
||||
left_side_layout = QtWidgets.QVBoxLayout(left_side_widget)
|
||||
left_side_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_side_layout.addWidget(header_widget)
|
||||
left_side_layout.addWidget(view)
|
||||
|
||||
details_widget = InstanceDetail(self)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.addWidget(left_side_widget, 0)
|
||||
layout.addWidget(details_widget, 1)
|
||||
|
||||
filter_input.textChanged.connect(proxy.setFilterFixedString)
|
||||
refresh_btn.clicked.connect(self._on_refresh_clicked)
|
||||
view.clicked.connect(self._on_activated)
|
||||
view.customContextMenuRequested.connect(self.on_context_menu)
|
||||
details_widget.save_triggered.connect(self._on_save)
|
||||
|
||||
self._model = model
|
||||
self._proxy = proxy
|
||||
self._view = view
|
||||
self._details_widget = details_widget
|
||||
self._refresh_btn = refresh_btn
|
||||
|
||||
def _on_refresh_clicked(self):
|
||||
self.refresh()
|
||||
|
||||
def _on_activated(self, index):
|
||||
container = None
|
||||
item_id = None
|
||||
if index.isValid():
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
container = self._model.get_instance_by_id(item_id)
|
||||
|
||||
self._details_widget.set_details(container, item_id)
|
||||
|
||||
def _on_save(self):
|
||||
host = api.registered_host()
|
||||
if not hasattr(host, "save_instances"):
|
||||
print("BUG: Host does not have \"save_instances\" method")
|
||||
return
|
||||
|
||||
current_index = self._view.selectionModel().currentIndex()
|
||||
if not current_index.isValid():
|
||||
return
|
||||
|
||||
item_id = current_index.data(ITEM_ID_ROLE)
|
||||
if item_id != self._details_widget.item_id():
|
||||
return
|
||||
|
||||
item_data = self._details_widget.instance_data_from_text()
|
||||
new_instances = []
|
||||
for index in iter_model_rows(self._model, 0):
|
||||
_item_id = index.data(ITEM_ID_ROLE)
|
||||
if _item_id == item_id:
|
||||
instance_data = item_data
|
||||
else:
|
||||
instance_data = self._model.get_instance_by_id(item_id)
|
||||
new_instances.append(instance_data)
|
||||
|
||||
host.save_instances(new_instances)
|
||||
|
||||
def on_context_menu(self, point):
|
||||
point_index = self._view.indexAt(point)
|
||||
item_id = point_index.data(ITEM_ID_ROLE)
|
||||
instance_data = self._model.get_instance_by_id(item_id)
|
||||
if instance_data is None:
|
||||
return
|
||||
|
||||
# Prepare menu
|
||||
menu = QtWidgets.QMenu(self)
|
||||
actions = []
|
||||
host = api.registered_host()
|
||||
if hasattr(host, "remove_instance"):
|
||||
action = QtWidgets.QAction("Remove instance", menu)
|
||||
action.setData(host.remove_instance)
|
||||
actions.append(action)
|
||||
|
||||
if hasattr(host, "select_instance"):
|
||||
action = QtWidgets.QAction("Select instance", menu)
|
||||
action.setData(host.select_instance)
|
||||
actions.append(action)
|
||||
|
||||
if not actions:
|
||||
actions.append(QtWidgets.QAction("* Nothing to do", menu))
|
||||
|
||||
for action in actions:
|
||||
menu.addAction(action)
|
||||
|
||||
# Show menu under mouse
|
||||
global_point = self._view.mapToGlobal(point)
|
||||
action = menu.exec_(global_point)
|
||||
if not action or not action.data():
|
||||
return
|
||||
|
||||
# Process action
|
||||
# TODO catch exceptions
|
||||
function = action.data()
|
||||
function(instance_data)
|
||||
|
||||
# Reset modified data
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
self._details_widget.set_details(None, None)
|
||||
self._model.refresh()
|
||||
|
||||
host = api.registered_host()
|
||||
dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or ""
|
||||
editable = False
|
||||
if dev_mode.lower() in ("1", "yes", "true", "on"):
|
||||
editable = hasattr(host, "save_instances")
|
||||
self._details_widget.set_editable(editable)
|
||||
|
||||
def showEvent(self, *args, **kwargs):
|
||||
super(SubsetManagerWindow, self).showEvent(*args, **kwargs)
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
self.refresh()
|
||||
|
||||
|
||||
def show(root=None, debug=False, parent=None):
|
||||
"""Display Scene Inventory GUI
|
||||
|
||||
Arguments:
|
||||
debug (bool, optional): Run in debug-mode,
|
||||
defaults to False
|
||||
parent (QtCore.QObject, optional): When provided parent the interface
|
||||
to this QObject.
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
module.window.close()
|
||||
del module.window
|
||||
except (RuntimeError, AttributeError):
|
||||
pass
|
||||
|
||||
with qt_app_context():
|
||||
window = SubsetManagerWindow(parent)
|
||||
window.show()
|
||||
|
||||
module.window = window
|
||||
|
||||
# Pull window to the front.
|
||||
module.window.raise_()
|
||||
module.window.activateWindow()
|
||||
|
|
@ -129,22 +129,20 @@ class HostToolsHelper:
|
|||
def get_subset_manager_tool(self, parent):
|
||||
"""Create, cache and return subset manager tool window."""
|
||||
if self._subset_manager_tool is None:
|
||||
from avalon.tools.subsetmanager import Window
|
||||
from openpype.tools.subsetmanager import SubsetManagerWindow
|
||||
|
||||
subset_manager_window = Window(parent=parent or self._parent)
|
||||
subset_manager_window = SubsetManagerWindow(
|
||||
parent=parent or self._parent
|
||||
)
|
||||
self._subset_manager_tool = subset_manager_window
|
||||
|
||||
return self._subset_manager_tool
|
||||
|
||||
def show_subset_manager(self, parent=None):
|
||||
"""Show tool display/remove existing created instances."""
|
||||
from avalon import style
|
||||
|
||||
subset_manager_tool = self.get_subset_manager_tool(parent)
|
||||
subset_manager_tool.show()
|
||||
|
||||
subset_manager_tool.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
# Pull window to the front.
|
||||
subset_manager_tool.raise_()
|
||||
subset_manager_tool.activateWindow()
|
||||
|
|
|
|||
1172
poetry.lock
generated
1172
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Test suite for User Settings."""
|
||||
# import pytest
|
||||
# from openpype.modules import ModulesManager
|
||||
|
||||
|
||||
def test_rr_job():
|
||||
# manager = ModulesManager()
|
||||
# rr_module = manager.modules_by_name["royalrender"]
|
||||
...
|
||||
Loading…
Add table
Add a link
Reference in a new issue