From a89a5d123029ad26d6d9e8e57e7161a8cd7db3e8 Mon Sep 17 00:00:00 2001 From: ccaillot Date: Fri, 20 Oct 2023 10:38:11 +0200 Subject: [PATCH] Add OpenRV Management --- openpype/hooks/pre_add_last_workfile_arg.py | 1 + openpype/hooks/pre_ocio_hook.py | 1 + openpype/hosts/nuke/api/pipeline.py | 47 ++- openpype/hosts/openrv/__init__.py | 10 + openpype/hosts/openrv/addon.py | 33 ++ openpype/hosts/openrv/api/__init__.py | 10 + openpype/hosts/openrv/api/commands.py | 34 ++ openpype/hosts/openrv/api/lib.py | 36 ++ openpype/hosts/openrv/api/ocio.py | 106 ++++++ openpype/hosts/openrv/api/pipeline.py | 233 +++++++++++++ openpype/hosts/openrv/api/review.py | 49 +++ openpype/hosts/openrv/hooks/__init__.py | 0 openpype/hosts/openrv/hooks/pre_ftrackdata.py | 19 + .../hosts/openrv/hooks/pre_setup_openrv.py | 63 ++++ openpype/hosts/openrv/plugins/__init__.py | 0 .../hosts/openrv/plugins/create/__init__.py | 0 .../plugins/create/create_annotations.py | 135 ++++++++ .../openrv/plugins/create/create_workfile.py | 97 ++++++ .../hosts/openrv/plugins/load/__init__.py | 0 .../hosts/openrv/plugins/load/load_mov.py | 87 +++++ .../hosts/openrv/plugins/load/loead_frames.py | 198 +++++++++++ .../hosts/openrv/plugins/publish/__init__.py | 0 .../plugins/publish/collect_workfile.py | 34 ++ .../plugins/publish/extract_annotations.py | 56 +++ .../startup/pkgs_source/comments/PACKAGE | 16 + .../startup/pkgs_source/comments/comments.py | 325 ++++++++++++++++++ .../pkgs_source/openpype_menus/PACKAGE | 16 + .../openpype_menus/openpype_menus.py | 131 +++++++ .../pkgs_source/openpype_scripteditor/PACKAGE | 16 + .../openpype_scripteditor.py | 82 +++++ .../action_review_openrv.py | 311 +++++++++++++++++ .../ftrack/event_handlers_user/action_rv.py | 72 ++-- openpype/resources/app_icons/openrv.png | Bin 0 -> 10126 bytes .../defaults/project_settings/openrv.json | 20 ++ .../system_settings/applications.json | 28 ++ openpype/settings/entities/enum_entity.py | 3 +- .../schemas/projects_schema/schema_main.json | 4 + .../schema_project_openrv.json | 41 +++ .../host_settings/schema_openrv.json | 39 +++ .../system_schema/schema_applications.json | 4 + 40 files changed, 2331 insertions(+), 26 deletions(-) create mode 100644 openpype/hosts/openrv/__init__.py create mode 100644 openpype/hosts/openrv/addon.py create mode 100644 openpype/hosts/openrv/api/__init__.py create mode 100644 openpype/hosts/openrv/api/commands.py create mode 100644 openpype/hosts/openrv/api/lib.py create mode 100644 openpype/hosts/openrv/api/ocio.py create mode 100644 openpype/hosts/openrv/api/pipeline.py create mode 100644 openpype/hosts/openrv/api/review.py create mode 100644 openpype/hosts/openrv/hooks/__init__.py create mode 100644 openpype/hosts/openrv/hooks/pre_ftrackdata.py create mode 100644 openpype/hosts/openrv/hooks/pre_setup_openrv.py create mode 100644 openpype/hosts/openrv/plugins/__init__.py create mode 100644 openpype/hosts/openrv/plugins/create/__init__.py create mode 100644 openpype/hosts/openrv/plugins/create/create_annotations.py create mode 100644 openpype/hosts/openrv/plugins/create/create_workfile.py create mode 100644 openpype/hosts/openrv/plugins/load/__init__.py create mode 100644 openpype/hosts/openrv/plugins/load/load_mov.py create mode 100644 openpype/hosts/openrv/plugins/load/loead_frames.py create mode 100644 openpype/hosts/openrv/plugins/publish/__init__.py create mode 100644 openpype/hosts/openrv/plugins/publish/collect_workfile.py create mode 100644 openpype/hosts/openrv/plugins/publish/extract_annotations.py create mode 100644 openpype/hosts/openrv/startup/pkgs_source/comments/PACKAGE create mode 100644 openpype/hosts/openrv/startup/pkgs_source/comments/comments.py create mode 100644 openpype/hosts/openrv/startup/pkgs_source/openpype_menus/PACKAGE create mode 100644 openpype/hosts/openrv/startup/pkgs_source/openpype_menus/openpype_menus.py create mode 100644 openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/PACKAGE create mode 100644 openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/openpype_scripteditor.py create mode 100644 openpype/modules/ftrack/event_handlers_user/action_review_openrv.py create mode 100644 openpype/resources/app_icons/openrv.png create mode 100644 openpype/settings/defaults/project_settings/openrv.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_openrv.json create mode 100644 openpype/settings/entities/schemas/system_schema/host_settings/schema_openrv.json diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 1418bc210b1..2c41f999873 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -27,6 +27,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "tvpaint", "substancepainter", "aftereffects", + "openrv" } launch_types = {LaunchTypes.local} diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index add3a0adaf1..1513c7aa4ba 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -19,6 +19,7 @@ class OCIOEnvHook(PreLaunchHook): "nuke", "hiero", "resolve", + "openrv" } launch_types = set() diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index a1d290646cb..f14fbb31f95 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -25,7 +25,7 @@ ) from openpype.pipeline.workfile import BuildWorkfile from openpype.tools.utils import host_tools - +from openpype.lib.applications import ApplicationManager from .command import viewer_update_and_undo_stop from .lib import ( Context, @@ -349,6 +349,51 @@ def _install_menu(): # adding shortcuts add_shortcuts_from_presets() + # adding rv + add_rv_from_presets() + +def add_rv_from_presets(): + rv_settings = get_current_project_settings()["openrv"]['openrv_nuke_integration'] + if not rv_settings['rvnuke_enabled']: + return + + app_manager = ApplicationManager() + openrv_app = app_manager.find_latest_available_variant_for_group("openrv") + rv_exec_path = str(openrv_app.find_executable()) + rv_root = os.path.dirname(os.path.dirname(rv_exec_path)) + rv_nuke_path = os.path.join(rv_root, 'plugins', 'SupportFiles', 'rvnuke') + if os.path.exists(rv_nuke_path): + nuke.pluginAddPath(rv_nuke_path) + log.info("RV Nuke path added: {}".format(rv_nuke_path)) + else: + log.warning("RV Nuke path not found: {}".format(rv_nuke_path)) + + if not rv_exec_path: + return + try: + import rvNuke + rv_pref_panel = rvNuke.RvPreferencesPanel() + rv_pref_panel.rvPrefs.prefs["rvExecPath"] = rv_exec_path + rv_pref_panel.rvPrefs.saveToDisk() + except ImportError: + log.warning("rvNuke not found") + + nuke.addOnCreate(add_rv_shortcut, nodeClass="Root") + return + + +def add_rv_shortcut(): + rv_global_settings = get_current_project_settings()["openrv"] + rv_settings = rv_global_settings['openrv_nuke_integration'] + if rv_settings['rvnuke_open_in_rv_shortcut']: + menubar = nuke.menu("Nuke") + menu_item = menubar.findItem("RV/View in RV") + if menu_item: + menu_item.setShortcut("Alt+v") + menu_item.setShortcut(rv_settings['rvnuke_open_in_rv_shortcut']) + log.info("Adding Shortcut `{}` to `{}`".format( + 'Open in RV', + rv_settings['rvnuke_open_in_rv_shortcut'])) def change_context_label(): menubar = nuke.menu("Nuke") diff --git a/openpype/hosts/openrv/__init__.py b/openpype/hosts/openrv/__init__.py new file mode 100644 index 00000000000..11ee189fce7 --- /dev/null +++ b/openpype/hosts/openrv/__init__.py @@ -0,0 +1,10 @@ +from .addon import ( + OpenRVAddon, + OPENRV_ROOT_DIR +) + + +__all__ = ( + "OpenRVAddon", + "OPENRV_ROOT_DIR" +) diff --git a/openpype/hosts/openrv/addon.py b/openpype/hosts/openrv/addon.py new file mode 100644 index 00000000000..3ae3992308e --- /dev/null +++ b/openpype/hosts/openrv/addon.py @@ -0,0 +1,33 @@ +import os +from openpype.modules import OpenPypeModule +from openpype.modules.interfaces import IHostAddon + +OPENRV_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class OpenRVAddon(OpenPypeModule, IHostAddon): + name = "openrv" + host_name = "openrv" + + def initialize(self, module_settings): + self.enabled = True + + def add_implementation_envs(self, env, app): + """Modify environments to contain all required for implementation.""" + # Set default environments if are not set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value + + def get_launch_hook_paths(self, app): + if app.host_name != self.host_name: + return [] + return [ + os.path.join(OPENRV_ROOT_DIR, "hooks") + ] + + def get_workfile_extensions(self): + return [".rv"] diff --git a/openpype/hosts/openrv/api/__init__.py b/openpype/hosts/openrv/api/__init__.py new file mode 100644 index 00000000000..e002b5459bd --- /dev/null +++ b/openpype/hosts/openrv/api/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""OpenRV OpenPype host API.""" + +from .pipeline import ( + OpenRVHost +) + +__all__ = [ + "OpenRVHost" +] diff --git a/openpype/hosts/openrv/api/commands.py b/openpype/hosts/openrv/api/commands.py new file mode 100644 index 00000000000..911cc4b12ef --- /dev/null +++ b/openpype/hosts/openrv/api/commands.py @@ -0,0 +1,34 @@ +import logging + +import rv +from openpype.pipeline.context_tools import get_current_project_asset + +log = logging.getLogger(__name__) + + +def reset_frame_range(): + """ Set timeline frame range. + """ + asset_doc = get_current_project_asset() + asset_name = asset_doc["name"] + asset_data = asset_doc["data"] + + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + + if frame_start is None or frame_end is None: + log.warning("No edit information found for {}".format(asset_name)) + return + + rv.commands.setFrameStart(frame_start) + rv.commands.setFrameEnd(frame_end) + rv.commands.setFrame(frame_start) + + +def set_session_fps(): + """ Set session fps. + """ + asset_doc = get_current_project_asset() + asset_data = asset_doc["data"] + fps = float(asset_data.get("fps", 25)) + rv.commands.setFPS(fps) diff --git a/openpype/hosts/openrv/api/lib.py b/openpype/hosts/openrv/api/lib.py new file mode 100644 index 00000000000..8fde5b9eb9e --- /dev/null +++ b/openpype/hosts/openrv/api/lib.py @@ -0,0 +1,36 @@ +import contextlib + +import rv + + +@contextlib.contextmanager +def maintained_view(): + """Reset to original view node after context""" + original = rv.commands.viewNode() + try: + yield + finally: + rv.commands.setViewNode(original) + + +@contextlib.contextmanager +def active_view(node): + """Set active view during context""" + with maintained_view(): + rv.commands.setViewNode(node) + yield + + +def group_member_of_type(group_node, member_type): + """Return first member of group that is of the given node type. + This is similar to `rv.extra_commands.nodesInGroupOfType` but only + returns the first entry directly if it has any match. + Args: + group_node (str): The group node to search in. + member_type (str): The node type to search for. + Returns: + str or None: The first member found of given type or None + """ + for node in rv.commands.nodesInGroup(group_node): + if rv.commands.nodeType(node) == member_type: + return node diff --git a/openpype/hosts/openrv/api/ocio.py b/openpype/hosts/openrv/api/ocio.py new file mode 100644 index 00000000000..6e3ab34059e --- /dev/null +++ b/openpype/hosts/openrv/api/ocio.py @@ -0,0 +1,106 @@ +"""Helper functions to apply OCIO colorspace settings on groups. +This tries to set the relevant OCIO settings on the group's look and render +pipeline similar to what the OpenColorIO Basic Color Management package does in +OpenRV through its `ocio_source_setup` python file. +This assumes that the OpenColorIO Basic Color Management package of RV is both +installed and loaded. +""" +import rv.commands +import rv.qtutils + +from .lib import ( + group_member_of_type, + active_view +) + + +class OCIONotActiveForGroup(RuntimeError): + """Error raised when OCIO is not enabled on the group node.""" + + +def get_group_ocio_look_node(group): + """Return OCIOLook node from source group""" + pipeline = group_member_of_type(group, "RVLookPipelineGroup") + if pipeline: + return group_member_of_type(pipeline, "OCIOLook") + + +def get_group_ocio_file_node(group): + """Return OCIOFile node from source group""" + pipeline = group_member_of_type(group, "RVLinearizePipelineGroup") + if pipeline: + return group_member_of_type(pipeline, "OCIOFile") + + +def set_group_ocio_colorspace(group, colorspace): + """Set the group's OCIOFile node ocio.inColorSpace property. + This only works if OCIO is already 'active' for the group. T + """ + import ocio_source_setup # noqa, RV OCIO package + node = get_group_ocio_file_node(group) + + if not node: + raise OCIONotActiveForGroup( + "Unable to find OCIOFile node for {}".format(group) + ) + + rv.commands.setStringProperty( + f"{node}.ocio.inColorSpace", [colorspace], True + ) + + +def set_current_ocio_active_state(state): + """Set the OCIO state for the currently active source. + This is a hacky workaround to enable/disable the OCIO active state for + a source since it appears to be that there's no way to explicitly trigger + this callback from the `ocio_source_setup.OCIOSourceSetupMode` instance + which does these changes. + """ + # TODO: Make this logic less hacky + # See: https://community.shotgridsoftware.com/t/how-to-enable-disable-ocio-and-set-ocio-colorspace-for-group-using-python/17178 # noqa + + group = rv.commands.viewNode() + ocio_node = get_group_ocio_file_node(group) + if state == bool(ocio_node): + # Already in correct state + return + + window = rv.qtutils.sessionWindow() + menu_bar = window.menuBar() + for action in menu_bar.actions(): + if action.text() != "OCIO" or action.toolTip() != "OCIO": + continue + + ocio_menu = action.menu() + + for ocio_action in ocio_menu.actions(): + if ocio_action.toolTip() == "File Color Space": + # The first entry is for "current source" instead + # of all sources so we need to break the for loop + # The first action of the file color space menu + # is the "Active" action. So lets take that one + active_action = ocio_action.menu().actions()[0] + + active_action.trigger() + return + + raise RuntimeError( + "Unable to set active state for current source. Make " + "sure the OCIO package is installed and loaded." + ) + + +def set_group_ocio_active_state(group, state): + """Set the OCIO state for the 'currently active source'. + This is a hacky workaround to enable/disable the OCIO active state for + a source since it appears to be that there's no way to explicitly trigger + this callback from the `ocio_source_setup.OCIOSourceSetupMode` instance + which does these changes. + """ + ocio_node = get_group_ocio_file_node(group) + if state == bool(ocio_node): + # Already in correct state + return + + with active_view(group): + set_current_ocio_active_state(state) diff --git a/openpype/hosts/openrv/api/pipeline.py b/openpype/hosts/openrv/api/pipeline.py new file mode 100644 index 00000000000..d47da499cd2 --- /dev/null +++ b/openpype/hosts/openrv/api/pipeline.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +import os +import json +from collections import OrderedDict + +import pyblish +import rv + +from openpype.host import HostBase, ILoadHost, IWorkfileHost, IPublishHost +from openpype.hosts.openrv import OPENRV_ROOT_DIR +from openpype.pipeline import ( + register_loader_plugin_path, + register_inventory_action_path, + register_creator_plugin_path, + AVALON_CONTAINER_ID, +) + +PLUGINS_DIR = os.path.join(OPENRV_ROOT_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +OPENPYPE_ATTR_PREFIX = "openpype." +JSON_PREFIX = "JSON:::" + + +class OpenRVHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): + name = "openrv" + + def __init__(self): + super(OpenRVHost, self).__init__() + self._op_events = {} + + def install(self): + pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_host("openrv") + + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) + register_inventory_action_path(INVENTORY_PATH) + + def open_workfile(self, filepath): + return rv.commands.addSources([filepath]) + + def save_workfile(self, filepath=None): + return rv.commands.saveSession(filepath) + + def work_root(self, session): + work_dir = session.get("AVALON_WORKDIR") + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + return os.path.join(work_dir, scene_dir) + else: + return work_dir + + def get_current_workfile(self): + filename = rv.commands.sessionFileName() + if filename == "Untitled": + return + else: + return filename + + def workfile_has_unsaved_changes(self): + # RV has `State.unsavedChanges` attribute however that appears to + # always return false and is never set to be true. As such, for now + # we always return False. + return False + + def get_workfile_extensions(self): + return [".rv"] + + def get_containers(self): + for container in get_containers(): + yield container + + def update_context_data(self, data, changes): + imprint("root", data, prefix=OPENPYPE_ATTR_PREFIX) + + def get_context_data(self): + return read("root", prefix=OPENPYPE_ATTR_PREFIX) + + +def imprint(node, data, prefix=None): + """Store attributes with value on a node. + Args: + node (object): The node to imprint data on. + data (dict): Key value pairs of attributes to create. + prefix (str): A prefix to add to all keys in the data. + Returns: + None + """ + node_prefix = f"{node}.{prefix}" if prefix else f"{node}." + for attr, value in data.items(): + # Create and set the attribute + prop = f"{node_prefix}.{attr}" + + if isinstance(value, (dict, list, tuple)): + value = f"{JSON_PREFIX}{json.dumps(value)}" + + if isinstance(value, (bool, int)): + type_name = "Int" + elif isinstance(value, float): + type_name = "Float" + elif isinstance(value, str): + type_name = "String" + else: + raise TypeError("Unsupport data type to imprint: " + "{} (type: {})".format(value, type(value))) + + if not rv.commands.propertyExists(prop): + type_ = getattr(rv.commands, f"{type_name}Type") + rv.commands.newProperty(prop, type_, 1) + set_property = getattr(rv.commands, f"set{type_name}Property") + set_property(prop, [value], True) + + +def read(node, prefix=None): + """Read properties from the given node with the values + This function assumes all read values are of a single width and will + return only the first entry. As such, arrays or multidimensional properties + will not be returned correctly. + Args: + node (str): Name of node. + prefix (str, optional): A prefix for the attributes to consider. + This prefix will be stripped from the output key. + Returns: + dict: The key, value of the properties. + """ + properties = rv.commands.properties(node) + node_prefix = f"{node}.{prefix}" if prefix else f"{node}." + type_getters = { + 1: rv.commands.getFloatProperty, + 2: rv.commands.getIntProperty, + # Not sure why 3, 4 and 5 don't seem to be types + 5: rv.commands.getHalfProperty, + 6: rv.commands.getByteProperty, + 8: rv.commands.getStringProperty + } + + data = {} + for prop in properties: + if prefix is not None and not prop.startswith(node_prefix): + continue + + info = rv.commands.propertyInfo(prop) + type_num = info["type"] + value = type_getters[type_num](prop) + if value: + value = value[0] + else: + value = None + + if type_num == 8 and value and value.strip().startswith(JSON_PREFIX): + # String + value = json.loads(value.strip()[len(JSON_PREFIX):]) + + key = prop[len(node_prefix):] + data[key] = value + + return data + + +def imprint_container(node, name, namespace, context, loader): + """Imprint `node` with container metadata. + Arguments: + node (object): The node to containerise. + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + context (dict): Asset information + loader (str): Name of loader used to produce this container. + Returns: + None + """ + + data = [ + ("schema", "openpype:container-2.0"), + ("id", str(AVALON_CONTAINER_ID)), + ("name", str(name)), + ("namespace", str(namespace)), + ("loader", str(loader)), + ("representation", str(context["representation"]["_id"])) + ] + + # We use an OrderedDict to make sure the attributes + # are always created in the same order. This is solely + # to make debugging easier when reading the values in + # the attribute editor. + imprint(node, OrderedDict(data), prefix=OPENPYPE_ATTR_PREFIX) + + +def parse_container(node): + """Returns imprinted container data of a tool + This reads the imprinted data from `imprint_container`. + """ + # If not all required data return None + required = ['id', 'schema', 'name', + 'namespace', 'loader', 'representation'] + + data = {} + for key in required: + prop = f"{node}.{OPENPYPE_ATTR_PREFIX}{key}" + if not rv.commands.propertyExists(prop): + return + + value = rv.commands.getStringProperty(prop)[0] + data[key] = value + + # Store the node's name + data["objectName"] = str(node) + + # Store reference to the node object + data["node"] = node + + return data + + +def get_container_nodes(): + """Return a list of node names that are marked as loaded container.""" + container_nodes = [] + for node in rv.commands.nodes(): + prop = f"{node}.{OPENPYPE_ATTR_PREFIX}schema" + if rv.commands.propertyExists(prop): + container_nodes.append(node) + return container_nodes + + +def get_containers(): + """Yield container data for each container found in current workfile.""" + for node in get_container_nodes(): + container = parse_container(node) + if container: + yield container diff --git a/openpype/hosts/openrv/api/review.py b/openpype/hosts/openrv/api/review.py new file mode 100644 index 00000000000..bc10dca89a9 --- /dev/null +++ b/openpype/hosts/openrv/api/review.py @@ -0,0 +1,49 @@ +"""review code""" +import os + +import rv + + +def get_path_annotated_frame(frame=None, asset=None, asset_folder=None): + """Get path for annotations + """ + # TODO: This should be less hardcoded + filename = os.path.normpath( + "{}/pyblish/exports/annotated_frames/annotate_{}_{}.jpg".format( + str(asset_folder), + str(asset), + str(frame) + ) + ) + return filename + + +def extract_annotated_frame(filepath=None): + """Export frame to file + """ + if filepath: + return rv.commands.exportCurrentFrame(filepath) + + +def review_attributes(node=None): + # TODO: Implement + # prop_status = node + ".openpype" + ".review_status" + # prop_comment = node + ".openpype" + ".review_comment" + pass + + +def get_review_attribute(node=None, attribute=None): + attr = node + ".openpype" + "." + attribute + return rv.commands.getStringProperty(attr)[0] + + +def write_review_attribute(node=None, attribute=None, att_value=None): + att_prop = node + ".openpype" + ".{}".format(attribute) + if not rv.commands.propertyExists(att_prop): + rv.commands.newProperty(att_prop, rv.commands.StringType, 1) + rv.commands.setStringProperty(att_prop, [str(att_value)], True) + + +def export_current_view_frame(frame=None, export_path=None): + rv.commands.setFrame(int(frame)) + rv.commands.exportCurrentFrame(export_path) diff --git a/openpype/hosts/openrv/hooks/__init__.py b/openpype/hosts/openrv/hooks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/openrv/hooks/pre_ftrackdata.py b/openpype/hosts/openrv/hooks/pre_ftrackdata.py new file mode 100644 index 00000000000..4a1e6f0e37e --- /dev/null +++ b/openpype/hosts/openrv/hooks/pre_ftrackdata.py @@ -0,0 +1,19 @@ +import json +import tempfile + +from openpype.lib import PreLaunchHook + + +class PreFtrackData(PreLaunchHook): + """Pre-hook for openrv/ftrack.""" + app_groups = ["openrv"] + + def execute(self): + + representations = self.data.get("extra", None) + if representations: + payload = {"representations": representations} + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as file: + json.dump(payload, file) + + self.launch_context.env["OPENPYPE_LOADER_REPRESENTATIONS"] = str(file.name) # noqa diff --git a/openpype/hosts/openrv/hooks/pre_setup_openrv.py b/openpype/hosts/openrv/hooks/pre_setup_openrv.py new file mode 100644 index 00000000000..c289817d652 --- /dev/null +++ b/openpype/hosts/openrv/hooks/pre_setup_openrv.py @@ -0,0 +1,63 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from openpype.lib import PreLaunchHook +from openpype.hosts.openrv import OPENRV_ROOT_DIR +from openpype.lib.execute import run_subprocess + + +class PreSetupOpenRV(PreLaunchHook): + """Pre-hook for openrv""" + app_groups = ["openrv"] + + def execute(self): + + executable = self.application.find_executable() + if not executable: + self.log.error("Unable to find executable for RV.") + return + + # We use the `rvpkg` executable next to the `rv` executable to + # install and opt-in to the OpenPype plug-in packages + rvpkg = Path(os.path.dirname(str(executable))) / "rvpkg" + packages_src_folder = Path(OPENRV_ROOT_DIR) / "startup" / "pkgs_source" + + # TODO: Are we sure we want to deploy the addons into a temporary + # RV_SUPPORT_PATH on each launch. This would create redundant temp + # files that remain on disk but it does allow us to ensure RV is + # now running with the correct version of the RV packages of this + # current running OpenPype version + op_support_path = Path(tempfile.mkdtemp( + prefix="openpype_rv_support_path_" + )) + + # Write the OpenPype RV package zips directly to the support path + # Packages/ folder then we don't need to `rvpkg -add` them afterwards + packages_dest_folder = op_support_path / "Packages" + packages_dest_folder.mkdir(exist_ok=True) + packages = ["comments", "openpype_menus", "openpype_scripteditor"] + for package_name in packages: + package_src = packages_src_folder / package_name + package_dest = packages_dest_folder / "{}".format(package_name) + self.log.debug(f"Writing: {package_dest}") + shutil.make_archive(str(package_dest), "zip", str(package_src)) + + # Install and opt-in the OpenPype RV packages + install_args = [rvpkg, "-only", op_support_path, "-install", "-force"] + install_args.extend(packages) + optin_args = [rvpkg, "-only", op_support_path, "-optin", "-force"] + optin_args.extend(packages) + run_subprocess(install_args, logger=self.log) + run_subprocess(optin_args, logger=self.log) + + self.log.debug(f"Adding RV_SUPPORT_PATH: {op_support_path}") + support_path = self.launch_context.env.get("RV_SUPPORT_PATH") + if support_path: + support_path = os.pathsep.join([support_path, + str(op_support_path)]) + else: + support_path = str(op_support_path) + self.log.debug(f"Setting RV_SUPPORT_PATH: {support_path}") + self.launch_context.env["RV_SUPPORT_PATH"] = support_path diff --git a/openpype/hosts/openrv/plugins/__init__.py b/openpype/hosts/openrv/plugins/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/openrv/plugins/create/__init__.py b/openpype/hosts/openrv/plugins/create/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/openrv/plugins/create/create_annotations.py b/openpype/hosts/openrv/plugins/create/create_annotations.py new file mode 100644 index 00000000000..0ce7bfffa20 --- /dev/null +++ b/openpype/hosts/openrv/plugins/create/create_annotations.py @@ -0,0 +1,135 @@ +import qtawesome +import rv + +from openpype.client import get_representations, get_asset_by_name +from openpype.hosts.openrv.api.pipeline import get_containers +from openpype.hosts.openrv.api import lib +from openpype.pipeline import get_current_project_name + +from openpype.pipeline import ( + AutoCreator, + CreatedInstance, +) + + +class AnnotationCreator(AutoCreator): + """Collect each drawn annotation over a loaded container as an annotation. + """ + identifier = "annotation" + family = "annotation" + label = "Annotation" + + default_variant = "Main" + + create_allow_context_change = False + + def create(self, options=None): + # We never create an instance since it's collected from user + # drawn annotations + pass + + def collect_instances(self): + + project_name = get_current_project_name() + + # Query the representations in one go (optimization) + # TODO: We could optimize more by first checking annotated frames + # and then only query the representations for those containers + # that have any annotated frames. + containers = list(get_containers()) + representation_ids = set(c["representation"] for c in containers) + representations = get_representations( + project_name, representation_ids=representation_ids + ) + representations_by_id = { + str(repre["_id"]): repre for repre in representations + } + + with lib.maintained_view(): + for container in containers: + self._collect_container(container, + project_name, + representations_by_id) + + def _collect_container(self, + container, + project_name, + representations_by_id): + + node = container["node"] + self.log.debug(f"Processing container node: {node}") + + # View this particular group to get its marked and annotated frames + # TODO: This will only find annotations on the actual source group + # and not for e.g. the source in the `defaultSequence`. + # For now it's easiest to enable 'Annotation > Configure > Draw On + # Source If Possible' so that most annotations end up on source + source_group = rv.commands.nodeGroup(node) + rv.commands.setViewNode(source_group) + annotated_frames = rv.extra_commands.findAnnotatedFrames() + if not annotated_frames: + return + + namespace = container["namespace"] + repre_id = container["representation"] + repre_doc = representations_by_id.get(repre_id) + if not repre_doc: + # This could happen if for example a representation was loaded + # through the library loader + self.log.warning(f"No representation found in database for " + f"container: {container}") + return + + repre_context = repre_doc["context"] + source_representation_asset = repre_context["asset"] + source_representation_task = repre_context["task"]["name"] + + # QUESTION Do we want to do anything with marked frames? + # for marked in marked_frames: + # print("MARKED ------------ ", container, marked, source_group) + + source_representation_asset_doc = get_asset_by_name( + project_name=project_name, + asset_name=source_representation_asset + ) + + for noted_frame in annotated_frames: + print(f"Found annotation for {source_group} frame {noted_frame}") + + variant = f"{namespace}_{noted_frame}" + subset_name = self.get_subset_name( + variant=variant, + task_name=source_representation_task, + asset_doc=source_representation_asset_doc, + project_name=project_name, + ) + data = { + "tags": ["review", "ftrackreview"], + "task": source_representation_task, + "asset": source_representation_asset, + "subset": subset_name, + "label": subset_name, + "publish": True, + "review": True, + "annotated_frame": noted_frame, + + # TODO: Retrieve actual review comment for annotated frame + "comment": "NEW COMMENT FROM UI {}".format(noted_frame), + } + + instance = CreatedInstance( + family=self.family, + subset_name=data["subset"], + data=data, + creator=self + ) + + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + # TODO: Implement storage of annotation instance settings + # Need to define where to store the annotation instance data. + pass + + def get_icon(self): + return qtawesome.icon("fa.comments", color="white") diff --git a/openpype/hosts/openrv/plugins/create/create_workfile.py b/openpype/hosts/openrv/plugins/create/create_workfile.py new file mode 100644 index 00000000000..d55056cae36 --- /dev/null +++ b/openpype/hosts/openrv/plugins/create/create_workfile.py @@ -0,0 +1,97 @@ +import qtawesome + +from openpype.hosts.openrv.api.pipeline import ( + read, imprint +) +from openpype.client import get_asset_by_name +from openpype.pipeline import ( + AutoCreator, + CreatedInstance, + legacy_io +) + + +class OpenRVWorkfileCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + label = "Workfile" + + default_variant = "Main" + + create_allow_context_change = False + + data_store_node = "root" + data_store_prefix = "openpype_workfile." + + def collect_instances(self): + + data = read(node=self.data_store_node, + prefix=self.data_store_prefix) + if not data: + return + + instance = CreatedInstance( + family=self.family, + subset_name=data["subset"], + data=data, + creator=self + ) + + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + for created_inst, _changes in update_list: + data = created_inst.data_to_store() + imprint(node=self.data_store_node, + data=data, + prefix=self.data_store_prefix) + + def create(self, options=None): + + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + project_name = legacy_io.Session["AVALON_PROJECT"] + asset_name = legacy_io.Session["AVALON_ASSET"] + task_name = legacy_io.Session["AVALON_TASK"] + host_name = legacy_io.Session["AVALON_APP"] + + if existing_instance is None: + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, + project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": self.default_variant + } + data.update(self.get_dynamic_data( + self.default_variant, task_name, asset_doc, + project_name, host_name, None + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, + project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name + + def get_icon(self): + return qtawesome.icon("fa.file-o", color="white") diff --git a/openpype/hosts/openrv/plugins/load/__init__.py b/openpype/hosts/openrv/plugins/load/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/openrv/plugins/load/load_mov.py b/openpype/hosts/openrv/plugins/load/load_mov.py new file mode 100644 index 00000000000..0d36e6c7bb7 --- /dev/null +++ b/openpype/hosts/openrv/plugins/load/load_mov.py @@ -0,0 +1,87 @@ +from openpype.pipeline import ( + load, + get_representation_context +) +from openpype.hosts.openrv.api.pipeline import imprint_container +from openpype.hosts.openrv.api.ocio import ( + set_group_ocio_active_state, + set_group_ocio_colorspace +) + +import rv + + +class MovLoader(load.LoaderPlugin): + """Load mov into OpenRV""" + + label = "Load MOV" + families = ["*"] + representations = ["*"] + extensions = ["mov", "mp4"] + order = 0 + + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + filepath = self.fname + # Command fails on unicode so we must force it to be strings + filepath = str(filepath) + + # node_name = "{}_{}".format(namespace, name) if namespace else name + namespace = namespace if namespace else context["asset"]["name"] + + loaded_node = rv.commands.addSourceVerbose([filepath]) + + # update colorspace + self.set_representation_colorspace(loaded_node, + context["representation"]) + + imprint_container( + loaded_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + node = container["node"] + + context = get_representation_context(representation) + filepath = load.get_representation_path_from_context(context) + filepath = str(filepath) + + # change path + rv.commands.setSourceMedia(node, [filepath]) + + # update colorspace + self.set_representation_colorspace(node, context["representation"]) + + # update name + rv.commands.setStringProperty(node + ".media.name", + ["newname"], True) + rv.commands.setStringProperty(node + ".media.repName", + ["repname"], True) + rv.commands.setStringProperty(node + ".openpype.representation", + [str(representation["_id"])], True) + + def remove(self, container): + node = container["node"] + group = rv.commands.nodeGroup(node) + rv.commands.deleteNode(group) + + def set_representation_colorspace(self, node, representation): + colorspace_data = representation.get("data", {}).get("colorspaceData") + if colorspace_data: + colorspace = colorspace_data["colorspace"] + # TODO: Confirm colorspace is valid in current OCIO config + # otherwise errors will be spammed from OpenRV for invalid space + + self.log.info(f"Setting colorspace: {colorspace}") + group = rv.commands.nodeGroup(node) + + # Enable OCIO for the node and set the colorspace + set_group_ocio_active_state(group, state=True) + set_group_ocio_colorspace(group, colorspace) diff --git a/openpype/hosts/openrv/plugins/load/loead_frames.py b/openpype/hosts/openrv/plugins/load/loead_frames.py new file mode 100644 index 00000000000..efdfc78f641 --- /dev/null +++ b/openpype/hosts/openrv/plugins/load/loead_frames.py @@ -0,0 +1,198 @@ +import copy + +import clique + +from openpype.pipeline import ( + load, + get_representation_context +) +from openpype.pipeline.load import get_representation_path_from_context +from openpype.lib.transcoding import IMAGE_EXTENSIONS + +from openpype.hosts.openrv.api.pipeline import imprint_container +from openpype.hosts.openrv.api.ocio import ( + set_group_ocio_active_state, + set_group_ocio_colorspace +) + +import rv + + +class FramesLoader(load.LoaderPlugin): + """Load frames into OpenRV""" + + label = "Load Frames" + families = ["*"] + representations = ["*"] + extensions = [ext.lstrip(".") for ext in IMAGE_EXTENSIONS] + order = 0 + + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + filepath = self._format_path(context) + # Command fails on unicode so we must force it to be strings + filepath = str(filepath) + + # node_name = "{}_{}".format(namespace, name) if namespace else name + namespace = namespace if namespace else context["asset"]["name"] + + loaded_node = rv.commands.addSourceVerbose([filepath]) + + # update colorspace + self.set_representation_colorspace(loaded_node, + context["representation"]) + + imprint_container( + loaded_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + node = container["node"] + + context = get_representation_context(representation) + filepath = self._format_path(context) + filepath = str(filepath) + + # change path + rv.commands.setSourceMedia(node, [filepath]) + + # update colorspace + self.set_representation_colorspace(node, context["representation"]) + + # update name + rv.commands.setStringProperty(node + ".media.name", + ["newname"], True) + rv.commands.setStringProperty(node + ".media.repName", + ["repname"], True) + rv.commands.setStringProperty(node + ".openpype.representation", + [str(representation["_id"])], True) + + def remove(self, container): + node = container["node"] + group = rv.commands.nodeGroup(node) + rv.commands.deleteNode(group) + + def _get_sequence_range(self, context): + """Return frame range for image sequences. + The start and end frame is based on the start frame and end frame of + the representation or version documents. A single frame is never + considered to be a sequence. + Warning: + If there are published sequences that do *not* have start and + end frame data in the database then this will FAIL to detect + it as a sequence. + Args: + context (dict): Representation context. + Returns: + tuple or None: (start, end) tuple if it is an image sequence + otherwise it returns None. + """ + version = context.get("version", {}) + representation = context.get("representation", {}) + + # Only images may be sequences, not videos + ext = representation.get("ext", representation.get("name")) + if f".{ext}" not in IMAGE_EXTENSIONS: + return + + for doc in [representation, version]: + # Frame range can be set on version or representation. + # When set on representation it overrides version data. + data = doc.get("data", {}) + start = data.get("frameStartHandle", data.get("frameStart", None)) + end = data.get("frameEndHandle", data.get("frameEnd", None)) + + if start is None or end is None: + continue + + if start != end: + return start, end + else: + # Single frame + return + + # Fallback for image sequence that does not have frame start and frame + # end stored in the database. + # TODO: Maybe rely on rv.commands.sequenceOfFile instead? + if "frame" in representation.get("context", {}): + # Guess the frame range from the files + files = representation.get("files", []) + if len(files) > 1: + paths = [f["path"] for f in representation["files"]] + collections, _remainder = clique.assemble(paths) + if collections: + collection = collections[0] + frames = list(collection.indexes) + return frames[0], frames[-1] + + return + + def _format_path(self, context): + """Format the path with correct frame range. + The openRV load command requires image sequences to be provided + with `{start}-{end}#` for its frame numbers, for example: + /path/to/sequence.1001-1010#.exr + """ + + sequence_range = self._get_sequence_range(context) + if not sequence_range: + return get_representation_path_from_context(context) + + context = copy.deepcopy(context) + representation = context["representation"] + template = representation.get("data", {}).get("template") + if not template: + # No template to find token locations for + return get_representation_path_from_context(context) + + def _placeholder(key): + # Substitute with a long placeholder value so that potential + # custom formatting with padding doesn't find its way into + # our formatting, so that wouldn't be padded as 0 + return "___{}___".format(key) + + # We format UDIM and Frame numbers with their specific tokens. To do so + # we in-place change the representation context data to format the path + # with our own data + start, end = sequence_range + tokens = { + "frame": f"{start}-{end}#", + } + has_tokens = False + repre_context = representation["context"] + for key, _token in tokens.items(): + if key in repre_context: + repre_context[key] = _placeholder(key) + has_tokens = True + + # Replace with our custom template that has the tokens set + representation["data"]["template"] = template + path = get_representation_path_from_context(context) + + if has_tokens: + for key, token in tokens.items(): + if key in repre_context: + path = path.replace(_placeholder(key), token) + + return path + + def set_representation_colorspace(self, node, representation): + colorspace_data = representation.get("data", {}).get("colorspaceData") + if colorspace_data: + colorspace = colorspace_data["colorspace"] + # TODO: Confirm colorspace is valid in current OCIO config + # otherwise errors will be spammed from OpenRV for invalid space + + self.log.info(f"Setting colorspace: {colorspace}") + group = rv.commands.nodeGroup(node) + + # Enable OCIO for the node and set the colorspace + set_group_ocio_active_state(group, state=True) + set_group_ocio_colorspace(group, colorspace) diff --git a/openpype/hosts/openrv/plugins/publish/__init__.py b/openpype/hosts/openrv/plugins/publish/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openpype/hosts/openrv/plugins/publish/collect_workfile.py b/openpype/hosts/openrv/plugins/publish/collect_workfile.py new file mode 100644 index 00000000000..b4eb5f72b5d --- /dev/null +++ b/openpype/hosts/openrv/plugins/publish/collect_workfile.py @@ -0,0 +1,34 @@ +import os +import pyblish.api +from openpype.pipeline import registered_host + + +class CollectWorkfile(pyblish.api.InstancePlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder - 0.01 + label = "OpenRV Session Workfile" + hosts = ["openrv"] + families = ["workfile"] + + def process(self, instance): + """Inject the current working file""" + + host = registered_host() + current_file = host.get_current_workfile() + if not current_file: + self.log.error("No current filepath detected. " + "Make sure to save your OpenRV session") + current_file = "" + + folder, file = os.path.split(current_file) + filename, ext = os.path.splitext(file) + + instance.context.data["currentFile"] = current_file + + instance.data['representations'] = [{ + 'name': ext.lstrip("."), + 'ext': ext.lstrip("."), + 'files': file, + "stagingDir": folder, + }] diff --git a/openpype/hosts/openrv/plugins/publish/extract_annotations.py b/openpype/hosts/openrv/plugins/publish/extract_annotations.py new file mode 100644 index 00000000000..cefe0f80f8e --- /dev/null +++ b/openpype/hosts/openrv/plugins/publish/extract_annotations.py @@ -0,0 +1,56 @@ +import os +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.openrv.api.review import ( + get_path_annotated_frame, + # extract_annotated_frame +) + + +class ExtractOpenRVAnnotatedFrames(publish.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract Annotations from Session" + hosts = ["openrv"] + families = ["annotation"] + + def process(self, instance): + + asset_folder = instance.data['asset_folder_path'] + asset = instance.data['asset'] + annotated_frame = instance.data['annotated_frame'] + + annotated_frame_path = get_path_annotated_frame( + frame=annotated_frame, + asset=asset, + asset_folder=asset_folder + ) + self.log.info("Annotated frame path: {}".format(annotated_frame_path)) + + annotated_frame_folder, file = os.path.split(annotated_frame_path) + if not os.path.isdir(annotated_frame_folder): + os.makedirs(annotated_frame_folder) + + # TODO: finish this extractor + # + # # save the frame + # + # # extract_annotated_frame(filepath=annotated_frame) + # + # assert os.path.isfile(annotated_frame) + # + # folder, file = os.path.split(annotated_frame) + # filename, ext = os.path.splitext(file) + # + # representation = { + # "name": ext.lstrip("."), + # "ext": ext.lstrip("."), + # "files": file, + # "stagingDir": folder, + # } + # + # if "representations" not in instance.data: + # instance.data["representations"] = [] + # + # instance.data["representations"].append(representation) diff --git a/openpype/hosts/openrv/startup/pkgs_source/comments/PACKAGE b/openpype/hosts/openrv/startup/pkgs_source/comments/PACKAGE new file mode 100644 index 00000000000..fc468b89ca0 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/comments/PACKAGE @@ -0,0 +1,16 @@ +package: comments +author: Aleks Katunar +organization: Artisan software Dobro +version: 1.0 +rv: 3.12 +openrv: 1.0.0 +requires: '' +optional: true + +modes: + - file: comments + load: immediate + +description: | + +

Adds Comments to OpenRV

diff --git a/openpype/hosts/openrv/startup/pkgs_source/comments/comments.py b/openpype/hosts/openrv/startup/pkgs_source/comments/comments.py new file mode 100644 index 00000000000..e447f84cf48 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/comments/comments.py @@ -0,0 +1,325 @@ +# review code +from PySide2 import QtCore, QtWidgets, QtGui + +from rv.rvtypes import MinorMode +import rv.qtutils +import rv.commands + + +def get_cycle_frame(frame=None, frames_lookup=None, direction="next"): + """Return nearest frame in direction in frames lookup. + If the nearest frame in that direction does not exist then cycle + over to the frames taking the first entry at the other end. + Note: + This function can return None if there are no frames to lookup in. + Args: + frame (int): frame to search from + frames_lookup (list): frames to search in. + direction (str, optional): search direction, either "next" or "prev" + Defaults to "next". + Returns: + int or None: The nearest frame number in that direction or None + if no lookup frames were passed. + """ + if direction not in {"prev", "next"}: + raise ValueError("Direction must be either 'next' or 'prev'. " + "Got: {}".format(direction)) + + if not frames_lookup: + return + + elif len(frames_lookup) == 1: + return frames_lookup[0] + + # We require the sorting of the lookup frames because we pass e.g. the + # result of `rv.extra_commands.findAnnotatedFrames()` as lookup frames + # which according to its documentations states: + # The array is not sorted and some frames may appear more than once. + frames_lookup = list(sorted(frames_lookup)) + if direction == "next": + # Return next nearest number or cycle to the lowest number + return next((i for i in frames_lookup if i > frame), + frames_lookup[0]) + elif direction == "prev": + # Return previous nearest number or cycle to the highest number + return next((i for i in reversed(frames_lookup) if i < frame), + frames_lookup[-1]) + + +class ReviewMenu(MinorMode): + def __init__(self): + MinorMode.__init__(self) + self.init("py-ReviewMenu-mode", None, None, + [("OpenPype", [ + ("_", None), # separator + ("Review", self.runme, None, self._is_active) + ])], + # initialization order + sortKey="source_setup", + ordering=20) + + # spacers + self.verticalSpacer = QtWidgets.QSpacerItem( + 20, 40, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding + ) + self.verticalSpacerMin = QtWidgets.QSpacerItem( + 2, 2, + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Minimum + ) + self.horizontalSpacer = QtWidgets.QSpacerItem( + 40, 10, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Minimum + ) + self.customDockWidget = QtWidgets.QWidget() + + # data + self.current_loaded_viewnode = None + self.review_main_layout = QtWidgets.QVBoxLayout() + self.rev_head_label = QtWidgets.QLabel("Shot Review") + self.set_item_font(self.rev_head_label, size=16) + self.rev_head_name = QtWidgets.QLabel("Shot Name") + self.current_loaded_shot = QtWidgets.QLabel("") + self.current_shot_status = QtWidgets.QComboBox() + self.current_shot_status.addItems([ + "In Review", "Ready For Review", "Reviewed", "Approved", "Deliver" + ]) + self.current_shot_comment = QtWidgets.QPlainTextEdit() + self.current_shot_comment.setStyleSheet( + "color: white; background-color: black" + ) + + self.review_main_layout_head = QtWidgets.QVBoxLayout() + self.review_main_layout_head.addWidget(self.rev_head_label) + self.review_main_layout_head.addWidget(self.rev_head_name) + self.review_main_layout_head.addWidget(self.current_loaded_shot) + self.review_main_layout_head.addWidget(self.current_shot_status) + self.review_main_layout_head.addWidget(self.current_shot_comment) + + self.get_view_image = QtWidgets.QPushButton("Get image") + self.review_main_layout_head.addWidget(self.get_view_image) + + self.remove_cmnt_status_btn = QtWidgets.QPushButton("Remove comment and status") # noqa + self.review_main_layout_head.addWidget(self.remove_cmnt_status_btn) + + self.rvWindow = None + self.dockWidget = None + + # annotations controls + self.notes_layout = QtWidgets.QVBoxLayout() + self.notes_layout_label = QtWidgets.QLabel("Annotations") + self.btn_note_prev = QtWidgets.QPushButton("Previous Annotation") + self.btn_note_next = QtWidgets.QPushButton("Next Annotation") + self.notes_layout.addWidget(self.notes_layout_label) + self.notes_layout.addWidget(self.btn_note_prev) + self.notes_layout.addWidget(self.btn_note_next) + + self.review_main_layout.addLayout(self.review_main_layout_head) + self.review_main_layout.addLayout(self.notes_layout) + self.review_main_layout.addStretch(1) + self.customDockWidget.setLayout(self.review_main_layout) + + # signals + self.current_shot_status.currentTextChanged.connect(self.setup_combo_status) # noqa + self.current_shot_comment.textChanged.connect(self.comment_update) + self.get_view_image.clicked.connect(self.get_gui_image) + self.remove_cmnt_status_btn.clicked.connect(self.clean_cmnt_status) + self.btn_note_prev.clicked.connect(self.annotate_prev) + self.btn_note_next.clicked.connect(self.annotate_next) + + def runme(self, arg1=None, arg2=None): + self.rvWindow = rv.qtutils.sessionWindow() + if self.dockWidget is None: + # Create DockWidget and add the Custom Widget on first run + self.dockWidget = QtWidgets.QDockWidget("OpenPype Review", + self.rvWindow) + self.dockWidget.setWidget(self.customDockWidget) + + # Dock widget to the RV MainWindow + self.rvWindow.addDockWidget(QtCore.Qt.RightDockWidgetArea, + self.dockWidget) + + self.setup_listeners() + else: + # Toggle visibility state + self.dockWidget.toggleViewAction().trigger() + + def _is_active(self): + if self.dockWidget is not None and self.dockWidget.isVisible(): + return rv.commands.CheckedMenuState + else: + return rv.commands.UncheckedMenuState + + def set_item_font(self, item, size=14, noweight=False, bold=True): + font = QtGui.QFont() + if bold: + font.setFamily("Arial Bold") + else: + font.setFamily("Arial") + font.setPointSize(size) + font.setBold(True) + if not noweight: + font.setWeight(75) + item.setFont(font) + + def setup_listeners(self): + # Some other supported signals: + # new-source + # graph-state-change, + # after-progressive-loading, + # media-relocated + rv.commands.bind("default", "global", "source-media-set", + self.graph_change, "Doc string") + rv.commands.bind("default", "global", "after-graph-view-change", + self.graph_change, "Doc string") + + def graph_change(self, event): + # update the view + self.get_view_source() + + def get_view_source(self): + sources = rv.commands.sourcesAtFrame(rv.commands.frame()) + self.current_loaded_viewnode = sources[0] if sources else None + self.update_ui_attribs() + + def update_ui_attribs(self): + node = self.current_loaded_viewnode + + # Use namespace as loaded shot label + namespace = "" + if node is not None: + property_name = "{}.openpype.namespace".format(node) + if rv.commands.propertyExists(property_name): + namespace = rv.commands.getStringProperty(property_name)[0] + + self.current_loaded_shot.setText(namespace) + + self.setup_properties() + self.get_comment() + + def setup_combo_status(self): + # setup properties + node = self.current_loaded_viewnode + att_prop = node + ".openpype_review.task_status" + status = self.current_shot_status.currentText() + rv.commands.setStringProperty(att_prop, [str(status)], True) + self.current_shot_status.setCurrentText(status) + + def setup_properties(self): + # setup properties + node = self.current_loaded_viewnode + if node is None: + self.current_shot_status.setCurrentIndex(0) + return + + att_prop = node + ".openpype_review.task_status" + if not rv.commands.propertyExists(att_prop): + status = "In Review" + rv.commands.newProperty(att_prop, rv.commands.StringType, 1) + rv.commands.setStringProperty(att_prop, [str(status)], True) + self.current_shot_status.setCurrentIndex(0) + else: + status = rv.commands.getStringProperty(att_prop)[0] + self.current_shot_status.setCurrentText(status) + + def comment_update(self): + node = self.current_loaded_viewnode + if node is None: + return + + comment = self.current_shot_comment.toPlainText() + att_prop = node + ".openpype_review.task_comment" + rv.commands.newProperty(att_prop, rv.commands.StringType, 1) + rv.commands.setStringProperty(att_prop, [str(comment)], True) + + def get_comment(self): + node = self.current_loaded_viewnode + if node is None: + self.current_shot_comment.setPlainText("") + return + + att_prop = node + ".openpype_review.task_comment" + if not rv.commands.propertyExists(att_prop): + rv.commands.newProperty(att_prop, rv.commands.StringType, 1) + rv.commands.setStringProperty(att_prop, [""], True) + else: + status = rv.commands.getStringProperty(att_prop)[0] + self.current_shot_comment.setPlainText(status) + + def clean_cmnt_status(self): + attribs = [] + node = self.current_loaded_viewnode + att_prop_cmnt = node + ".openpype_review.task_comment" + att_prop_status = node + ".openpype_review.task_status" + attribs.append(att_prop_cmnt) + attribs.append(att_prop_status) + + for prop in attribs: + if not rv.commands.propertyExists(prop): + rv.commands.newProperty(prop, rv.commands.StringType, 1) + rv.commands.setStringProperty(prop, [""], True) + + self.current_shot_status.setCurrentText("In Review") + self.current_shot_comment.setPlainText("") + + def get_gui_image(self, filename=None): + + if not filename: + # Allow user to pick filename + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self.customDockWidget, + "Save image", + "image.png", + "Images (*.png *.jpg *.jpeg *.exr)" + ) + if not filename: + # User cancelled + return + + rv.commands.exportCurrentFrame(filename) + print("Current frame exported to: {}".format(filename)) + + def annotate_next(self): + """Set frame to next annotated frame""" + all_notes = self.get_annotated_for_view() + if not all_notes: + return + nxt = get_cycle_frame(frame=rv.commands.frame(), + frames_lookup=all_notes, + direction="next") + + rv.commands.setFrame(int(nxt)) + rv.commands.redraw() + + def annotate_prev(self): + """Set frame to previous annotated frame""" + all_notes = self.get_annotated_for_view() + if not all_notes: + return + previous = get_cycle_frame(frame=rv.commands.frame(), + frames_lookup=all_notes, + direction="prev") + rv.commands.setFrame(int(previous)) + rv.commands.redraw() + + def get_annotated_for_view(self): + """Return the frame numbers for all annotated frames""" + annotated_frames = rv.extra_commands.findAnnotatedFrames() + return annotated_frames + + def get_task_status(self): + import ftrack_api + session = ftrack_api.Session(auto_connect_event_hub=False) + self.log.debug("Ftrack user: \"{0}\"".format(session.api_user)) + # project_name = legacy_io.Session["AVALON_PROJECT"] + # project_entity = session.query(( + # "select project_schema from Project where full_name is \"{}\"" + # ).format(project_name)).one() + # project_schema = project_entity["project_schema"] + + +def createMode(): + return ReviewMenu() diff --git a/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/PACKAGE b/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/PACKAGE new file mode 100644 index 00000000000..b4a407a35a1 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/PACKAGE @@ -0,0 +1,16 @@ +package: openpype_menus +author: Aleks Katunar +organization: Artisan software Dobro +version: 1.0 +rv: 3.12 +openrv: 1.0.0 +requires: '' +optional: true + +modes: + - file: openpype_menus + load: immediate + +description: | + +

Adds OpenPype to OpenRV

diff --git a/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/openpype_menus.py b/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/openpype_menus.py new file mode 100644 index 00000000000..9e06a711a99 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/openpype_menus.py @@ -0,0 +1,131 @@ +import os +import json +import sys +import importlib + +import rv.qtutils +from rv.rvtypes import MinorMode + +from openpype.tools.utils import host_tools +from openpype.client import get_representations +from openpype.pipeline import ( + registered_host, + install_host, + discover_loader_plugins, + load_container +) +from openpype.hosts.openrv.api import OpenRVHost + +# TODO (Critical) Remove this temporary hack to avoid clash with PyOpenColorIO +# that is contained within OpenPype's venv +# Ensure PyOpenColorIO is loaded from RV instead of from OpenPype lib by +# moving all rv related paths to start of sys.path so RV libs are imported +# We consider the `/openrv` folder the root to `/openrv/bin/rv` executable +rv_root = os.path.normpath(os.path.dirname(os.path.dirname(sys.executable))) +rv_paths = [] +non_rv_paths = [] +for path in sys.path: + if os.path.normpath(path).startswith(rv_root): + rv_paths.append(path) + else: + non_rv_paths.append(path) +sys.path[:] = rv_paths + non_rv_paths + +import PyOpenColorIO # noqa +importlib.reload(PyOpenColorIO) + + +def install_openpype_to_host(): + host = OpenRVHost() + install_host(host) + + +class OpenPypeMenus(MinorMode): + + def __init__(self): + MinorMode.__init__(self) + self.init( + name="py-openpype", + globalBindings=None, + overrideBindings=None, + menu=[ + # Menu name + # NOTE: If it already exists it will merge with existing + # and add submenus / menuitems to the existing one + ("OpenPype", [ + # Menuitem name, actionHook (event), key, stateHook + ("Create...", self.create, None, None), + ("Load...", self.load, None, None), + ("Publish...", self.publish, None, None), + ("Manage...", self.scene_inventory, None, None), + ("Library...", self.library, None, None), + ("_", None), # separator + ("Work Files...", self.workfiles, None, None), + ]) + ], + # initialization order + sortKey="source_setup", + ordering=15 + ) + + @property + def _parent(self): + return rv.qtutils.sessionWindow() + + def create(self, event): + host_tools.show_publisher(parent=self._parent, + tab="create") + + def load(self, event): + host_tools.show_loader(parent=self._parent, use_context=True) + + def publish(self, event): + host_tools.show_publisher(parent=self._parent, + tab="publish") + + def workfiles(self, event): + host_tools.show_workfiles(parent=self._parent) + + def scene_inventory(self, event): + host_tools.show_scene_inventory(parent=self._parent) + + def library(self, event): + host_tools.show_library_loader(parent=self._parent) + + +def data_loader(): + incoming_data_file = os.environ.get( + "OPENPYPE_LOADER_REPRESENTATIONS", None + ) + if incoming_data_file: + with open(incoming_data_file, 'rb') as file: + decoded_data = json.load(file) + os.remove(incoming_data_file) + load_data(dataset=decoded_data["representations"]) + else: + print("No data for auto-loader") + + +def load_data(dataset=None): + + project_name = os.environ["AVALON_PROJECT"] + available_loaders = discover_loader_plugins(project_name) + Loader = next(loader for loader in available_loaders + if loader.__name__ == "FramesLoader") + + representations = get_representations(project_name, + representation_ids=dataset) + + for representation in representations: + load_container(Loader, representation) + + +def createMode(): + # This function triggers for each RV session window being opened, for + # example when using File > New Session this will trigger again. As such + # we only want to trigger the startup install when the host is not + # registered yet. + if not registered_host(): + install_openpype_to_host() + data_loader() + return OpenPypeMenus() diff --git a/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/PACKAGE b/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/PACKAGE new file mode 100644 index 00000000000..7f865d2bd40 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/PACKAGE @@ -0,0 +1,16 @@ +package: openpype_scripteditor +author: Roy Nieterau +organization: Colorbleed +version: 1.0 +rv: 3.12 +openrv: 1.0.0 +requires: '' +optional: true + +modes: + - file: openpype_scripteditor + load: immediate + +description: | + +

Adds OpenPype Script Editor to OpenRV

diff --git a/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/openpype_scripteditor.py b/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/openpype_scripteditor.py new file mode 100644 index 00000000000..2cb26e438f2 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/openpype_scripteditor/openpype_scripteditor.py @@ -0,0 +1,82 @@ +import rv.commands +import rv.qtutils +from rv.rvtypes import MinorMode + +from qtpy import QtCore + +# On OpenPype installation it moves `openpype.modules` entries into +# `openpype_modules`. However, if OpenPype installation has not triggered yet. +# For example when the openpype_menus RV package hasn't loaded then the move +# of that package hasn't happened. So we'll allow both ways to import to ensure +# it is found +try: + from openpype_modules.python_console_interpreter.window import PythonInterpreterWidget # noqa +except ModuleNotFoundError: + from openpype.modules.python_console_interpreter.window import PythonInterpreterWidget # noqa + + +class OpenPypeMenus(MinorMode): + + def __init__(self): + MinorMode.__init__(self) + self.init( + name="py-openpype-scripteditor", + globalBindings=None, + overrideBindings=None, + menu=[ + # Menu name + # NOTE: If it already exists it will merge with existing + # and add submenus / menuitems to the existing one + ("Tools", [ + # Menuitem name, actionHook (event), key, stateHook + ( + "Script Editor", + self.show_scripteditor, + None, + self.is_active + ), + ]) + ], + # initialization order + sortKey="source_setup", + ordering=25 + ) + + self._widget = None + + @property + def _parent(self): + return rv.qtutils.sessionWindow() + + def show_scripteditor(self, event): + """Show the console - create if not exists""" + if self._widget is not None: + if self._widget.isVisible(): + # Closing also saves the scripts directly. + # Thus we prefer to close instead of hide here + self._widget.close() + return + else: + self._widget.show() + self._widget.raise_() + return + + widget = PythonInterpreterWidget(parent=self._parent) + widget.setWindowTitle("Python Script Editor - OpenRV") + widget.setWindowFlags(widget.windowFlags() | + QtCore.Qt.Dialog | + QtCore.Qt.WindowMinimizeButtonHint) + widget.show() + widget.raise_() + + self._widget = widget + + def is_active(self): + if self._widget is not None and self._widget.isVisible(): + return rv.commands.CheckedMenuState + else: + return rv.commands.UncheckedMenuState + + +def createMode(): + return OpenPypeMenus() diff --git a/openpype/modules/ftrack/event_handlers_user/action_review_openrv.py b/openpype/modules/ftrack/event_handlers_user/action_review_openrv.py new file mode 100644 index 00000000000..36b1e256fac --- /dev/null +++ b/openpype/modules/ftrack/event_handlers_user/action_review_openrv.py @@ -0,0 +1,311 @@ +import os +import traceback +import json +from collections import defaultdict + +from openpype.client import ( + get_asset_by_name, + get_subset_by_name, + get_version_by_name, + get_representation_by_name, get_project +) +from openpype.lib import ApplicationManager +from openpype.pipeline import AvalonMongoDB + +from openpype_modules.ftrack.lib import BaseAction, statics_icon + + +class RVActionReview(BaseAction): + """ Launch RV action """ + identifier = "openrv.review.action" + label = "Review with RV" + description = "OpenRV Launcher" + icon = statics_icon("ftrack", "action_icons", "RV.png") + + type = "Application" + + allowed_types = ["img", "mov", "exr", "mp4", "jpg", "png"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.rv_path = None + self.rv_app = "openrv/1-0" + self.application_manager = ApplicationManager() + + def discover(self, session, entities, event): + """Return available actions based on *event*. """ + data = event['data'] + selection = data.get('selection', []) + print(selection[0]["entityType"]) + if selection[0]["entityType"] == "list": + return { + 'items': [{ + 'label': self.label, + 'description': self.description, + 'actionIdentifier': self.identifier + }] + } + + def preregister(self): + return True + + def get_components_from_list_entity(self, entity): + """Get components from list entity types. + The components dictionary is modifid in place, so nothing is returned. + Args: + entity (Ftrack entity) + components (dict) + """ + items_components = [] + if entity.entity_type.lower() == "assetversionlist": + + for item in entity["items"]: + print("item in assetversionlist", item) + components = dict() + + if item.entity_type.lower() == "assetversion": + for component in item["components"]: + if component["file_type"][ + 1:] not in self.allowed_types: + continue + try: + components[item["asset"]["parent"]["name"]].append( + component) + except KeyError: + components[item["asset"]["parent"]["name"]] = [ + component] + + items_components.append(components) + + return items_components + + def interface(self, session, entities, event): + if event['data'].get('values', {}): + return + + user = session.query( + "User where username is '{0}'".format( + os.environ["FTRACK_API_USER"] + ) + ).one() + job = session.create( + "Job", + { + "user": user, + "status": "running", + "data": json.dumps({ + "description": "RV: Collecting components." + }) + } + ) + # Commit to feedback to user. + session.commit() + items = [] + + try: + items = self.get_interface_items(session, entities) + except Exception: + self.log.error(traceback.format_exc()) + job["status"] = "failed" + else: + job["status"] = "done" + + job["status"] = "done" + + # Commit to end job. + session.commit() + + return {"items": items} + + def get_interface_items(self, session, entities): + + all_item_for_ui = [] + for entity in entities: + print("ENTITY", entity) + + item_components = self.get_components_from_list_entity(entity) + for components in item_components: + print("Working on", components) + # Sort by version + for parent_name, entities in components.items(): + version_mapping = defaultdict(list) + for entity in entities: + entity_version = entity["version"]["version"] + version_mapping[entity_version].append(entity) + + # Sort same versions by date. + for version, entities in version_mapping.items(): + version_mapping[version] = sorted( + entities, + key=lambda x: x["version"]["date"], + reverse=True + ) + + components[parent_name] = [] + for version in reversed(sorted(version_mapping.keys())): + components[parent_name].extend( + version_mapping[version] + ) + + # Items to present to user. + label = "{} - v{} - {}" + loadables = ["exr"] + for parent_name, entities in components.items(): + data = [] + for entity in entities: + entity_filetype = entity["file_type"][1:] + if entity_filetype in loadables: + data.append( + { + "label": label.format( + entity["version"]["asset"]["name"], + str(entity["version"]["version"]).zfill(3), # noqa + entity["file_type"][1:] + ), + "value": entity["id"] + } + ) + + all_item_for_ui.append( + { + "label": parent_name, + "type": "enumerator", + "name": parent_name, + "data": data, + "value": data[0]["value"] + } + ) + return all_item_for_ui + + def launch(self, session, entities, event): + """Callback method for RV action.""" + # Launching application + if "values" not in event["data"]: + return + + user = session.query( + "User where username is '{0}'".format( + os.environ["FTRACK_API_USER"] + ) + ).one() + job = session.create( + "Job", + { + "user": user, + "status": "running", + "data": json.dumps({ + "description": "RV: Collecting file paths." + }) + } + ) + # Commit to feedback to user. + session.commit() + + component_representation = [] + + try: + component_representation = self.get_representations( + session, event, entities + ) + except Exception: + self.log.error(traceback.format_exc()) + job["status"] = "failed" + else: + job["status"] = "done" + + # Commit to end job. + session.commit() + + # launch app here + avalon_project_apps = event["data"].get("avalon_project_apps", None) + avalon_project_doc = event["data"].get("avalon_project_doc", None) + + if avalon_project_apps is None: + if avalon_project_doc is None: + ft_project = self.get_project_from_entity(entities[0]) + project_name = ft_project["full_name"] + avalon_project_doc = get_project(project_name) or False + event["data"]["avalon_project_doc"] = avalon_project_doc + + if not avalon_project_doc: + return False + + project_apps_config = avalon_project_doc["config"].get("apps", []) + avalon_project_apps = ( + [app["name"] for app in project_apps_config] or False + ) + event["data"]["avalon_project_apps"] = avalon_project_apps + + # set app + for a in avalon_project_apps: + if "openrv" in a: + self.rv_app = a + + # checks for what are we loading + task_name = "prepDaily" + asset_name = "DaliesPrep" + + self.application_manager.launch( + self.rv_app, + project_name=project_name, + asset_name=asset_name, + task_name=task_name, + extra=component_representation + ) + return True + + def get_representations(self, session, event, entities): + """Get representations from selected components.""" + + ft_project = self.get_project_from_entity(entities[0]) + project_name = ft_project["full_name"] + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + representations = [] + + for parent_name in sorted(event["data"]["values"].keys()): + componenet_check = event["data"]["values"][parent_name] + if type(componenet_check) is list: + component_data = event["data"]["values"][parent_name][0] + else: + component_data = event["data"]["values"][parent_name] + + component = session.get("Component", component_data) + subset_name = component["version"]["asset"]["name"] + version_name = component["version"]["version"] + representation_name = component["file_type"][1:] + + asset_doc = get_asset_by_name( + project_name, parent_name, fields=["_id"] + ) + subset_doc = get_subset_by_name( + project_name, + subset_name=subset_name, + asset_id=asset_doc["_id"] + ) + version_doc = get_version_by_name( + project_name, + version=version_name, + subset_id=subset_doc["_id"] + ) + repre_doc = get_representation_by_name( + project_name, + version_id=version_doc["_id"], + representation_name=representation_name + ) + if not repre_doc: + repre_doc = get_representation_by_name( + project_name, + version_id=version_doc["_id"], + representation_name="preview" + ) + representations.append(str(repre_doc["_id"])) + + return representations + + +def register(session): + """Register hooks.""" + RVActionReview(session).register() diff --git a/openpype/modules/ftrack/event_handlers_user/action_rv.py b/openpype/modules/ftrack/event_handlers_user/action_rv.py index 39cf33d6056..7ab9dd95b52 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_rv.py +++ b/openpype/modules/ftrack/event_handlers_user/action_rv.py @@ -1,7 +1,10 @@ +import getpass import os import subprocess +import sys import traceback import json +from collections import defaultdict import ftrack_api @@ -19,10 +22,10 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon -class RVAction(BaseAction): +class RVActionView(BaseAction): """ Launch RV action """ - identifier = "rv.launch.action" - label = "rv" + identifier = "openrv.launch.action" + label = "Open with RV" description = "rv Launcher" icon = statics_icon("ftrack", "action_icons", "RV.png") @@ -33,18 +36,24 @@ class RVAction(BaseAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # TODO (Critical) This should not be as hardcoded as it is now # QUESTION load RV application data from AppplicationManager? rv_path = None + rv_path = "PATH_TO_BIN/bin/rv.exe" + self.rv_home = "PATH_TO+RV_HOME" + os.environ["RV_HOME"] = os.path.normpath(self.rv_home) + sys.path.append(os.path.join(self.rv_home, "lib")) + # RV_HOME should be set if properly installed - if os.environ.get('RV_HOME'): - rv_path = os.path.join( - os.environ.get('RV_HOME'), - 'bin', - 'rv' - ) - if not os.path.exists(rv_path): - rv_path = None + # if os.environ.get('RV_HOME'): + # rv_path = os.path.join( + # os.environ.get('RV_HOME'), + # 'bin', + # 'rv' + # ) + # if not os.path.exists(rv_path): + # rv_path = None if not rv_path: self.log.info("RV path was not found.") @@ -54,7 +63,16 @@ def __init__(self, *args, **kwargs): def discover(self, session, entities, event): """Return available actions based on *event*. """ - return True + data = event['data'] + selection = data.get('selection', []) + print(selection[0]["entityType"]) + if selection[0]["entityType"] != "list": + return {'items': [{ + 'label': self.label, + 'description': self.description, + 'actionIdentifier': self.identifier + }] + } def preregister(self): if self.rv_path is None: @@ -66,7 +84,7 @@ def preregister(self): def get_components_from_entity(self, session, entity, components): """Get components from various entity types. - The components dictionary is modified in place, so nothing is returned. + The components dictionary is modifid in place, so nothing is returned. Args: entity (Ftrack entity) @@ -157,14 +175,10 @@ def get_interface_items(self, session, entities): # Sort by version for parent_name, entities in components.items(): - version_mapping = {} + version_mapping = defaultdict(list) for entity in entities: - try: - version_mapping[entity["version"]["version"]].append( - entity - ) - except KeyError: - version_mapping[entity["version"]["version"]] = [entity] + entity_version = entity["version"]["version"] + version_mapping[entity_version].append(entity) # Sort same versions by date. for version, entities in version_mapping.items(): @@ -248,10 +262,20 @@ def launch(self, session, entities, event): args.extend(["-fps", str(fps)]) args.extend(paths) - + # CORE EDIT SET UP THE PATHS + # TODO (Critical) This should not be as hardcoded as it is now + self.log.info("setting up env vars") + os.environ["RV_HOME"] = os.path.normpath(self.rv_home) + sys.path.append(os.path.join(self.rv_home, "lib")) + sys.path.append(self.rv_home) self.log.info("Running rv: {}".format(args)) - - subprocess.Popen(args) + self.home = os.path.normpath( + os.path.join("c:/Users", getpass.getuser()) + ) + os.environ["HOME"] = self.home + env = os.environ.copy() + env['PYTHONPATH'] = '' + subprocess.Popen(args, env=env) return True @@ -328,4 +352,4 @@ def get_file_paths(self, session, event): def register(session): """Register hooks.""" - RVAction(session).register() + RVActionView(session).register() diff --git a/openpype/resources/app_icons/openrv.png b/openpype/resources/app_icons/openrv.png new file mode 100644 index 0000000000000000000000000000000000000000..741e7a9772bc76815556d92323542be25f069d63 GIT binary patch literal 10126 zcmeHtS6CBI`0i{HlF%d4i$p=1^d_Cqq&JZ!MWpv8O;89(5u_=C2!bFey$VPtAc&we z1qGx8kt$tKIw!yXxji@M=3Je9_IYM@cBky@H{X2wzHhvdfhIKtD+K_6T3bu~IshEy0ypxcwv0xkR>}5Cjt|tgtl&?3D?M~lo8e! zpr1ETVFIuY4YGwBs5W)7234|GvT!{{C{`H8iU2N`Am@wFB_!~<4Et@4ju(N2^1~#k zAv0EJ`Z_9z59YxQTQNhwxC;Mfh$@sr2vb517oju>_=rAIgB~K8*aKnMGbwnBI@x6u zaJvL?B7q4DRH1|zH9$71lKn77?OCDA6cM%;plW5rvMISL9fW{DmnK=NIBfPhs#p;r zLkq20pojF3+6>TJ9HL2$Oc4W-Z1{~l;*ltf5e^3RkmH7^UM=L2EyboKdd>v(SqC|P z1C=EW-?@oC2Ln_Pz+ZvizXI#iMzSNoOIi4V4aK1?1quRxttgCGAP4}`hu*iQxXlUq zUH)%v;s)MB5-GJ)`CK5Uq=LPPIbPk1<#3Z6k+iYs2*$h@b{n3iLF|Fuel~U+b zZno^Jdfee%Z}LMa!QM_*Y@FbBWt6=xdMEqM-UZ_dMAuXLTuGOr#>FSkT6n)HU?TOH zgM2tG^EMCF*Wo?fcYc1PCH{rc4Ie$E+CEsLV1!OW_K`ri-q{fVEYMb0xe+qCo)dhJ zp@*?|+tRo|az!7JF3@(AN|&{3M5iiHn2J$5RJcK3gHO;pA3dy2uJhQIE&;0!Lu(nL z`4OVrIJzge9rCIB&^bE)&6%8u=Q$H?OaESmH{Nj`I&dCZdn&Pj`S`|^#u@-T0GR)L z1P%Z+01&BvfcU>Cd?++a{3~p*-rq4^-CZgyIT2f>GwPYIQSY9)HHnJlRlUQ6w^@wblF3*q{Eto6Rx}eJVpgz z0Hd!WOyhN*yW-c^Z=ILX0JA94ahEzcLfhk@<-6+)91trz3*=1GLP~BH89#tPa>SwV zUY2(2Sb4540uWSe4X8`C?Tc^1c|`!uWa7IU9bbGQfhTYc0u`r!t^U%ccbmALW!M66 zXF_WIf^dB4)`tE%02!r{!66QCA3Iuf(WDkT zxjeKFuU9<&3xH7l1rELy!^i?FIH3Rat*)rhe9v=|x>qb4Den2 zvycNfm%CooIe-tO&L(oPm@1u*g}_b%^MU}2Wo5x709d5Fcvx%(++#_vaQIVrB5LXg z0Oz8UW}Svtm7RbvzdG{KvX(>k>M8`DUSFqq;#H#N1&EK=!gL#oX8r<#GKHJ0ajk~F zCkqTrW&e#ZsWoj|fk5NkL#>7@oQ7A~P;zIo{nMi3;T`kB*2QJxJ(n3qL| zuqZCwmH?yCBQIr5%iP$2Iu+i+Y8a1Z1B4m@#N?||Io1n+$sGMQ(b|TJ2Jga$ur$lh zy@tdtdp)c+ZR2pX0PH8&R*GLsu14Ty=dYJtZk=Yah&*qZXtVsT->kIw)Ip3nRUnNR zGdOpGEw)%?y3Ge->tj;wS0`>GVR=6=XugKn00}BE)Ji$WJ=`+HjKEKBs))w#+Ip!0 z(J7RwHs8xRB*=?WMm(`NWeA|f$v=E0Q2$jH+{UP;Q+$;In20@rDhI%W+vjtQ@`mvH zzwvl~OvDc!JO>cL5qEJoS?05ezwf{ma7}f%*u8KNEK^=AC~t3u5y*ogzM2)MC4*vd zBTnITWt~(pU z6B{a_8UU`TrDnR<5y8_qjF{Qod?=VkIu82CZxHn~p?6(cqnjneDj1ddjryeYLh7?H!bAvIK!3xpD!w(Q-tXuUuP zrL|D;%ohZZmm%ja{V?eU;dy0lTHJLfIpI+l#{4{d91i|L3FV@TfIvyGtHS$l!K&BI zak3%M_}?pe?Dt^ff8)RfaL$PKMu1eJ9XVh}GTy2ZM-bkz?3;&-?Kq*p(+49DY#mTI z)2&LH8|f@St+T0l*vJe7pk`N9r&*va&SG>aZOmyLHP|E6%NZ;300d5judP@YP=doK zhroh%L9hkySiKfb1zgFz#~$AtK>>pYE82S{a8V_r$cf{!j$Q-HR?(FRHvfXk+=H~q z`Cn%_e*AMgr}O-rxk_ypuXuczt~fHNN@&gs&o2FXdh_u1uY{I~BLkP*6S4Krvlbm~ zCu);(nQm;gE?zR(MvfTrL*$kbJVzxM9M9ts3pCXp* z!8(#M^X^YggU|Ylw?h77G0`16f@cCg?l(u1%jItkh#b71=%x82?z?bb`+u_z3CfKx^A^0BuEKh%Y(Bg>)TFrR zWs|p2x}A<;6tNh6V$WcRAQ+%0!I3 za2jtSR^=of?u1!d8S7D1NND2Ua_EC{cJ$dOcd~bGaxYs)L{IsZ-cM9%KGkAR9g`1( zU17txZ*jf)sw>|}@mDQNkmDzZ2AbMhdbh<+RwD#KH;B%zPe0ABSxMjTQe%HDXPWMh z$)V3iJLe=!AlqG#T7~wVzv5CL%IO_U<9YY!8zBl{G1z!s`DyjuWnDG4ekChcQ6Vtd zcev%D0*MY-xl5gH_az=}&2d?oXSVaxrfyL16-mQ?4V-5HUuV+40gDgKl%I6VrK{`J zv*7eVbmrDz9666){r5?EqWR)mx)jfz>Uw(fzbiA$hA6yTty1DSX7IH_Z3lST&pgni z%(t3g?dLhBF#@>rw&(fjcYANua*D3WV@`&d=T~+XoO7$l89>y%#^eH1_k^jZ!?bJb zx58aLYhrJ`pryZ{4#x}Z_>BCqhucl|da!PBu1C18$yuo>XNM64B*Aj25747@{2R%P z#t$rBsJ=o5R9SUG4WOP|AHNI!J$aJBPXtzK9a&+7*o&*svcrPzYUMJ>na*npuQ&3AjVcP7HWTo|AT1Wli|~bcMQFt?a6#FRXctcxf}} zt-qHj?B5!?EM7=@^hmy$YtOs$UV6CBvk9i! z^%2T7XHUJs=R?vM@$G|{T?w~OKQnR?+AqzQF29d2`djF7Pc`8B8ewGj#bo2trMXF~4o_RoQquN6003Sr>ZSp7u_^h_|N(8jM; z|GPrlGIEqv^O|V6cFOPavilM@_tTA<_n*LrZSSvh9MeW`I?lSee4~SjlD(Zj;_q<$ z+z#`V7-;f9Ty!<00Wlu0{62lUy_Iev;t!_yfiHTK#r#U~iFrUG0`GCA@3V}80xia84g6fhl=LI?}jIM%mugp6?v>d>}W3p zR2i=5O`|na4KHtA88^1L={r;XO!9uz_Y%jBZN8wXc*qFxBzNKw(??O(LAR#b*Ryd~ zVFSzY*4gdcx1gtO%8#!R;_2~B#XvNsk8{NN{2!{Mh>#E#+dm;kZdd6viDcJ-z;y_C z95^KZbkp<%2J^v+aqCztrAMe_jtq=J8!FZy1LgV!*$h|1R3BU=Fns>1@Tba7Dn&}8 zHdX;vkA~1s>QKbwpU!vuAnd`znK(BGT7!A3>MR43b5U4v&_4oUGpK=!FVu(|4xSQv z(BjA1D`yg@s*k>z1{p=tfZz#$wdtlBbV1zjh-%_{C}k=Ucfw7Cn_}Sv6;I&H8(owZ z5YDxzD*a)M*#f2Ee9PoK3?&X$A5nwFTB6*mK@mcspw8W|@mYGX>F+-rLE?Ef@R`dk zJ6`T(+;dlFejnD~2{G)Z7nwDY__A?eSBWZovUlN*@G|W}800&AIG<38X{3WhZRtq$ zItDoVLObXrP<*!4omq~DW=>Qe(!elja=wVgxkMKf|Hy2CiKk1m$A5K?>Sx})2oX7I zAi@iDmfqz3&o$1GUxH?PCoUIBBR5llsE#KPrRCnhu7_XB&1s{Um9)4_@vkJQlaCJA z(zD_@#qRu}Eu8L%ZQ^Nt*-5GAg-o^OhKP3`Lyd|i1ypz&l;K>d&|rQlywqV!fgH@M zBe9f!$f^@t&os9Mg$mDXjdP%1JTO7HXa)-OKCO=rnTE3=|Q;pACRfJ@9!qBaUYr5MGm%}gn-n-LX9<#elro{KcH-RNY=rI z^KFVMrgaNq{!E6?{`Mm~A)+`ay%aT`?6Q^-6_A!k8tV^c&vW>EYanWQWF^$!t?VBz zY&d;huT!Fk7(H(7C4uLr?m-bRD}j$9Vfuy_i<@$epZD_;1mnty?EX(au;4<;Iq~KP z0K<{C&PAldrowMc#da+d!+VKsq*b%v@uG0Cm$Nje|gNKen zx(lIY`}ehTZ83uBVUgt`NDD-svLSF?`Tipy_clCt;?qvW+|>0{{Uzvd9zJY#2CS{G z{bmuR>_5FUJ1I9Hfgfe$1j=o2qsB|NuXtKl;!+%c9O@lWVGriuqUy7N{xnP2n0}G0 z$;9oxiBUiJg1HA;_oxU-YgF;2qO=L0%o30FNsr&1hl?6_fESONc#O@IZ1uD1#q}$e zQlY~5eIWCj9zQk_#*OT1IX-Fryyn0jM}?K0hhr)=f!VB07O&X3#TaGrs*NgHNAR5E zA|sRioHB>(}Oijardy+^)7-Z=!($R}Cm;L6i7)Pccj-P|Z+0%@= zrB54UR(`tOGg<_IS-ys1aq*WJ0{ls^ew6-ROh9e=GYV`;`h1@VaYn3lttPxq@Jkb& z9Or|;z62hXayo#-&xg!%F@EroS%?&Y1NF(Hy^ zTRD$6pPV>0P=DcYO;Q410ojQU482YUGk5D=nl3&5bPw7ZIr86PV&a42gSCe5^)y-- zun)m0#8nWS|R|Q`nLXu)Sv2}C-_Ool1e#L!ao3_nWVt9H+tN3`i@`6MxUKeuCXpdFL?tP$qB-Q623&GbU~ov$=C>}Rno-6ms+#_RCxei3-;D4MmO z7Qf(kIcWd%dH8|rugWv@6KL5M_2tai?^ookl$2HIM#two4&uutlF*Nm^FOz zyE#T(W+aF)IY_diO^Ktr<-@;!npwPYG`|ROUcg>{=F4mS!eLdea?D= z(#TiC-B_>_i_g`4Dvwm2ZA@uZ5P9;*}1T=guycaw6+5Y`iK>|#)MOu5)C{~pZuv>$M>${8M z?C&`h_eaQcS@3I%!?zWHQRp_$V*O)*s#_uz9cg}oJ{@P->(&x@NxiG@;z;4}9~#s| zt<88>^V`Lj34Sh3&Y6+Xu`yBuN!qdqKVWcd#XK5( z6S~ehB~SDVlm1S%CCvh8F2V6()MTb{r-=@775gY%Qd51ARCtO@@alwFtkIJoVrIL! zm~gfSGF2cI8oucWM3oWmt_QjzlUKqR=ARnccspPsN~BnT3OkT_Q#aPpRDqhmKQ{Yv zI@#3+CyGie;Fxw6oc|><{NM-TKfimX9~XO6Iq+&SAJPt-!E>QbGq6>0 z?Y+V`=}D9aZx}VpE(=51n-eh z(Y6@KE!y~&6xs)_hxvnhEyWGVcW1L!GQRX+S`}4zOOApfZN+g+xBS5{c5JaGc#~f! zr2$hl!9E#2(4xhG=i>oXssdoSeDavp?8jBHSwV?n`M+$F+Z|Fl&@sw7oQ8-)*liO{&{C;%!$!aY;beyS0WC#~t%ueRosNURKSI4TxrcPf#9f z{5z?w=!nl+1!%KInTFwR3lvX!L#E2>vGb)H^p=$msIX*Za8Pe7DHcB6ZGBZj=edGf z(b#!+o`=b+Sa_X^0K}X}&}v3*Uw!^$=SI1h<*1SrWlmVn5P*-uj5H!2|KZVp+V`_c zMAjyeoge_eVN8j`cYG_j{pl9!a@onk7kASE`rEIzSP05EAy8a27Blis3YIr|1{7KwjmJ z^sD+u5q(iFS9Wje8_msG*GS;Z)gf?&Gs;B@LHt(mL-G$rNqy*zn7C)38Rg(N@*!O6 zvEollR+ydKpN|_n?n&25{kF{nY-B$3oX)980-U~M(!Gky;PSP|no(BMdIy=D582Dk z=h#3NRsf7XmJ+wr1qNc7Xd_va+PqHTjcY7O^J$>&vqY?v@^-O$QOB^3{(=D2Yz$L>1;+yXBX^ZN)!X<*TCc$A zV2~t~s(4Y7-;xdV3r}{JaYoSFzMA)`%~C=AeBK5^FP4IjjLD}qY5Nhko$K0f>|NKB z7K%#~q^xZ37CgN!+YIuSekE3nU{7P5mA?lUd+xE4Kp9CHYg_ql8YR)X12e`}`-mWlYD0g8# z!-Y+Pyu%$vkxfUI)8FD&&f+w5(#==X|FVBRyv4xw@}+;w?c>*n59L5bd8S~0eW>dG z;hUGwBC>Ltxp+4APt#?O{%Q6s82kthJ~(*(4f&_WWTD8u<5GOY8VfQx<+N@zkuWnR zpZ4U5v+yyohT;7k`ATiQu>?}gK9n`mdQ!7!5I?B=?qDO<)YxrB9OdV8mdcpU)>L6v z^ZfPLqbubp>HjNvt42#pvZrk-PcAkRZVA5#EyU08Ulg;13RDN z0|dHn0WZ zX(9N17zVPt%E&HM{2$N`(yzQ$5!_|`g1}!;BaEs34<4VYC`j|OC^+>Rzy|v^;QeVw zsPIZeFK7(qhQ!z3vB%Psrbr_43TS1lMGpc->A(%Rf^eogRv$v|AfHjb=6z5{2HFrx zOgbVLlOWs!!V1g{^MxJX=|EWTa{X+#4&j6dn_b0ZKLJRD$2<{{kL16LakXxW$eF{|j7jbJ*?rE|^%gC65kTTt?xq#qzdn1&= zj|_`&jy`ZVLf-;LPWyDkLoy>4KP2e?;G2H_Ck^nVoi9Gr0wA_i`=TwYydDA=-6_WQ zYvvC;#sDnpAsLe_Rt5NmF-CQ&C{C07Wngp`H0fcl$xQ`{DZ16}P;0^o2jVAp^ENNK)yWoe&P-=22@brmu<&Kss7l zx&-%}qn81;iUz~uyh@CTMv=y+7*)ROs96XH!zR8r@FapuwV9-IiCF=RJ1hwSFi;93 zD_r)_V<=<;spr?|izAB0qm}4mUg9X0GtSJyx0x) zb@sY&FxdWy0KFp@{-3)HL0p@1p=yxOmw zDtrJfV5RYRP`JaAOa(yc$%W`ATAO3nNKC3C9Cb}=cSGVM7UX&R&MCCV2B?vES85MR zsAu%;`_xE)Bk_ubBwjI+iVRS!P^t77Enyr*0j89V#?Q2PE6-^vi6qV`{!a_goTZ_m z28_zgv2_W%I#(REKxzRe!aVYr{jmy!Q}O+p7ud!TO+^V*bE|Jz+vev^BGo-WD&-5d zw6roxaT)NjMj(2EM2cQ|5653Z;_`%Cafn)o)R!f-z1OudcYl&KfYjbQm<{pJLHGm} z__(2g-ghW-_ALljpw9OMUyGTswC^k9neJO_Tw z(R7Mrw4i#KeaS#m4B6zpefayS)BzaYD9lPkOu15V7HlCw)7{CP8i>RSJI(|86oluy z#%#w)5h!aalXxinC)g9p#Rq+?Tnm0!XiMX`B&#y+K9JrE}}to@k8SeGm@)v z0;+;>kB~t1Jr8rR`>;!w=*X1N1%OUsw)^}W`GJc0V(wcF1i08c;=?R_?2{NoKQd+y z!>7h|VnQ71;xdyN8SwUqP&VFki7P_x`(#Lv?+s59W8Y;N*SF?-PGgYXdHIO5G^BQC?ZxH}6{vLn~LFwiS@b|KEgnq}?q< zRJ1l@=FPF@ q-cF!~{?Edf{|o&KNj{T$dpAP2i}K^K6G|J94s8ts^-5K{sQ(4I#c_)O literal 0 HcmV?d00001 diff --git a/openpype/settings/defaults/project_settings/openrv.json b/openpype/settings/defaults/project_settings/openrv.json new file mode 100644 index 00000000000..116132f0743 --- /dev/null +++ b/openpype/settings/defaults/project_settings/openrv.json @@ -0,0 +1,20 @@ +{ + "imageio": { + "activate_host_color_management": true, + "remapping": { + "rules": [] + }, + "ocio_config": { + "override_global_config": false, + "filepath": [] + }, + "file_rules": { + "activate_host_rules": false, + "rules": {} + } + }, + "openrv_nuke_integration": { + "rvnuke_enabled": false, + "rvnuke_open_in_rv_shortcut" : "Alt+v" + } +} diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f2fc7d933aa..9d3622bd55a 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1578,6 +1578,34 @@ "1-1": "1.1" } } + }, + "openrv": { + "enabled": true, + "label": "OpenRV", + "icon": "{}/app_icons/openrv.png", + "host_name": "openrv", + "environment": {}, + "variants": { + "1-0": { + "use_python_2": false, + "executables": { + "windows": [ + "PATH_TO_OPEN_RV_BIN/bin/rv.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, + "__dynamic_keys_labels__": { + "1-0": "1.0" + } + } }, "additional_apps": {} } diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 26ecd33551a..b8e3e6c4477 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -172,7 +172,8 @@ class HostsEnumEntity(BaseEnumEntity): "standalonepublisher", "substancepainter", "traypublisher", - "webpublisher" + "webpublisher", + "openrv" ] def _item_initialization(self): diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 4315987a33e..ba027837fac 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -149,6 +149,10 @@ { "type": "schema", "name": "schema_project_standalonepublisher" + }, + { + "type": "schema", + "name": "schema_project_openrv" }, { "type": "schema", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_openrv.json b/openpype/settings/entities/schemas/projects_schema/schema_project_openrv.json new file mode 100644 index 00000000000..8ed04381138 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_openrv.json @@ -0,0 +1,41 @@ +{ + "type": "dict", + "collapsible": true, + "key": "openrv", + "label": "OpenRV", + "is_file": true, + "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (remapped to OCIO)", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "template", + "name": "template_host_color_management_remapped" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "openrv_nuke_integration", + "label": "Nuke integration", + "children": [ + { + "type": "boolean", + "key": "rvnuke_enabled", + "label": "Enable RV Nuke integration" + }, + + { + "type": "text", + "key": "rvnuke_open_in_rv_shortcut", + "label": "Open In Rv Shortcut" + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_openrv.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_openrv.json new file mode 100644 index 00000000000..f2677c84362 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_openrv.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "openrv", + "label": "OpenRV", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index abea37a9ab7..9cd566db3cd 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -105,6 +105,10 @@ "type": "schema", "name": "schema_djv" }, + { + "type": "schema", + "name": "schema_openrv" + }, { "type": "dict-modifiable", "key": "additional_apps",