diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 1418bc210b1..bd58bd8e17d 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 e695cf3fe80..67898280c50 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/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..4d3fcd43243 --- /dev/null +++ b/openpype/hosts/openrv/api/lib.py @@ -0,0 +1,39 @@ +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..6b848b1bc27 --- /dev/null +++ b/openpype/hosts/openrv/api/ocio.py @@ -0,0 +1,115 @@ +"""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..79beba16b29 --- /dev/null +++ b/openpype/hosts/openrv/api/pipeline.py @@ -0,0 +1,245 @@ +# -*- 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/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..0f1921d4f2e --- /dev/null +++ b/openpype/hosts/openrv/hooks/pre_setup_openrv.py @@ -0,0 +1,64 @@ +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 / "{}.zip".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/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/load_frames.py b/openpype/hosts/openrv/plugins/load/load_frames.py new file mode 100644 index 00000000000..3b0bc2fafa2 --- /dev/null +++ b/openpype/hosts/openrv/plugins/load/load_frames.py @@ -0,0 +1,205 @@ +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/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/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..c3cb0e9bbda --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/comments/PACKAGE @@ -0,0 +1,17 @@ +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..0b364cc8269 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/comments/comments.py @@ -0,0 +1,330 @@ +# 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..6fec18e6ea9 --- /dev/null +++ b/openpype/hosts/openrv/startup/pkgs_source/openpype_menus/PACKAGE @@ -0,0 +1,17 @@ +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..1635db021a4 --- /dev/null +++ b/openpype/modules/ftrack/event_handlers_user/action_review_openrv.py @@ -0,0 +1,313 @@ +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 00000000000..741e7a9772b Binary files /dev/null and b/openpype/resources/app_icons/openrv.png differ diff --git a/openpype/settings/defaults/project_settings/openrv.json b/openpype/settings/defaults/project_settings/openrv.json new file mode 100644 index 00000000000..af98c5e2b30 --- /dev/null +++ b/openpype/settings/defaults/project_settings/openrv.json @@ -0,0 +1,13 @@ +{ + "imageio": { + "activate_host_color_management": true, + "ocio_config": { + "override_global_config": false, + "filepath": [] + }, + "file_rules": { + "activate_host_rules": false, + "rules": {} + } + } +} diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index a5283751e97..0e5b31d755a 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1615,5 +1615,33 @@ } } }, + "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": {} -} +} \ No newline at end of file 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..91baeb85af5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -150,6 +150,10 @@ "type": "schema", "name": "schema_project_standalonepublisher" }, + { + "type": "schema", + "name": "schema_project_openrv" + }, { "type": "schema", "name": "schema_project_traypublisher" 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..09d03e8c4e5 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_openrv.json @@ -0,0 +1,22 @@ +{ + "type": "dict", + "collapsible": true, + "key": "openrv", + "label": "OpenRV", + "is_file": true, + "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (OCIO managed)", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "template", + "name": "template_host_color_management_ocio" + } + ] + } + ] +} 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 7965c344aee..1163e4570c0 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -109,6 +109,10 @@ "type": "schema", "name": "schema_djv" }, + { + "type": "schema", + "name": "schema_openrv" + }, { "type": "dict-modifiable", "key": "additional_apps",