Merge branch 'develop' into feature/creator_in_openpype

This commit is contained in:
iLLiCiTiT 2021-11-15 10:51:13 +01:00
commit 66812f01be
28 changed files with 2183 additions and 527 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
from .royal_render_module import RoyalRenderModule
__all__ = (
"RoyalRenderModule",
)

View 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

View file

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

View file

@ -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"))
]
]

View file

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

View file

@ -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")]
}

View 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")

View file

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

View file

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

View file

@ -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):
"""

View file

@ -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):
"""

View file

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

View file

@ -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": {

View file

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

View file

@ -167,6 +167,16 @@
"ffmpeg": 48
}
},
"royalrender": {
"enabled": false,
"rr_paths": {
"default": {
"windows": "",
"darwin": "",
"linux": ""
}
}
},
"log_viewer": {
"enabled": true
},

View file

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

View file

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

View 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

View file

@ -0,0 +1,9 @@
from .window import (
show,
SubsetManagerWindow
)
__all__ = (
"show",
"SubsetManagerWindow"
)

View 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
)

View 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)

View 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()

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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"]
...